Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbfda83e14 | ||
|
|
8fde1c9362 | ||
|
|
9febb8bb60 | ||
|
|
a754fc0282 | ||
|
|
6f17a5d4a8 | ||
|
|
9ed3f7169e | ||
|
|
c4a9d47180 | ||
|
|
8971748d5c | ||
|
|
0ac6636d08 | ||
|
|
f6faf97cdf | ||
|
|
6bb3ee4bc7 | ||
|
|
7bda138d69 | ||
|
|
9b51d61519 | ||
|
|
8e42862d20 | ||
|
|
f593b2c7b8 | ||
|
|
fc65fdb451 | ||
|
|
8171354e2a | ||
|
|
cb16d9757e | ||
|
|
96d5535293 | ||
|
|
f5b49d49ee | ||
|
|
fd45ba1112 | ||
|
|
f13e5b752a | ||
|
|
4106c3c846 | ||
|
|
044e573933 | ||
|
|
3d8ee52a15 | ||
|
|
1ddcffc511 | ||
|
|
31f6237446 | ||
|
|
6dff5f19c1 | ||
|
|
9f5fac2753 | ||
|
|
c05bdec252 | ||
|
|
e4d1ba720a | ||
|
|
5d7747839c | ||
|
|
fd0489a22f | ||
|
|
7ea189aa61 | ||
|
|
1cc75591fa | ||
|
|
895737febc | ||
|
|
5e9d030297 | ||
|
|
2b02de2193 | ||
|
|
13481936f0 | ||
|
|
603d5a6f7f | ||
|
|
eb3dbe048b | ||
|
|
4af4a65527 | ||
|
|
a52872bea8 | ||
|
|
06fe1f4ed7 | ||
|
|
e2863c1f6c | ||
|
|
15aeb64b19 | ||
|
|
8a894e8365 | ||
|
|
e3fea18ab2 | ||
|
|
58702da156 | ||
|
|
c342b91029 | ||
|
|
792425a83e | ||
|
|
ac2e61731c | ||
|
|
62cbca4a9d | ||
|
|
11bb70316a | ||
|
|
9fc79dfc07 | ||
|
|
60e91d1371 | ||
|
|
a45c2aeafa | ||
|
|
f63dfde85d | ||
|
|
5ac39ac7a2 | ||
|
|
733615b9e0 | ||
|
|
d0507481cb | ||
|
|
98343e55d1 | ||
|
|
6f575d6a23 | ||
|
|
24756128e0 | ||
|
|
19dc698f46 | ||
|
|
0a7b5fa00f | ||
|
|
22c282d8fa | ||
|
|
86cda294f9 | ||
|
|
9db3fa697d | ||
|
|
6565714ee4 | ||
|
|
1783f0e6bc | ||
|
|
0a7f4de321 | ||
|
|
e8b7647f3d | ||
|
|
0284b33e50 | ||
|
|
d3809b5805 | ||
|
|
a50f9b6644 | ||
|
|
f77b3a5c41 | ||
|
|
3b087850e7 | ||
|
|
19f61ac454 |
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: add-language
|
||||
description: Add or remove a UI language/locale — config, catalog generation, translation agents, SMS/date/meta integration, and verification. Use when asked to add, enable, support, or remove a language.
|
||||
---
|
||||
|
||||
# Adding a language
|
||||
|
||||
Example: adding French (`fr`). Five places name the locale set — update all:
|
||||
|
||||
1. `src/lib/i18n/index.ts` — `SUPPORTED_LOCALES` + `LOCALE_LABELS` (label in
|
||||
the language itself, no region clarifier: `fr: "Français"`).
|
||||
2. `lingui.config.ts` — `locales`.
|
||||
3. `src/lib/mantine/mantine-provider.tsx` — `DAYJS_LOCALE` map + the matching
|
||||
`import 'dayjs/locale/fr'` (calendar month/weekday names come from dayjs;
|
||||
without this, calendars stay English).
|
||||
4. `src/lib/i18n/meta.ts` — `OG_LOCALE_BY_LOCALE` (fr → fr_FR).
|
||||
5. `src/lib/twilio/index.ts` — `TWILIO_VERIFY_LOCALES` ONLY if Twilio Verify
|
||||
supports the code (https://www.twilio.com/docs/verify/supported-languages).
|
||||
Unsupported codes must stay out — the allowlist silently falls back to the
|
||||
service-default English SMS instead of failing the login send.
|
||||
|
||||
Then generate + translate:
|
||||
|
||||
6. `bun run extract` creates `src/locales/fr/messages.po` (every msgid, empty
|
||||
msgstr).
|
||||
7. Translate via a chunked agent workflow (this repo's proven recipe):
|
||||
- Split en.po entry blocks (split on blank lines, skip header) into ~60-entry
|
||||
chunks; one sonnet agent per chunk RETURNS translated entries (never let
|
||||
parallel agents edit one .po). Validate each returned chunk by msgid count;
|
||||
retry failures once.
|
||||
- Merge by msgid back into the locale .po. Match msgids by exact bytes —
|
||||
watch non-breaking spaces (\xa0) in msgids, which agents normalize away;
|
||||
patch those few by hand.
|
||||
- Agent rules: msgstr single-line, ICU placeholders/plural keywords and
|
||||
`<0>` tags preserved (translate only human words inside branches; plural
|
||||
categories follow the target language's CLDR set), FLXN/Flexxon/Spotify
|
||||
untranslated, casual sporty tone, register decided up front (tú/du/です・ます).
|
||||
8. `bun run i18n:check` must pass (0 missing). Optionally run per-locale
|
||||
native-reviewer agents over the full .po (fidelity, register, glossary
|
||||
consistency, ICU integrity) and apply their FIX lines.
|
||||
9. `bunx tsc --noEmit && bun run build`; confirm the new locale appears as its
|
||||
own lazy `messages-*.js` chunk in dist/client/assets.
|
||||
10. Manual check: switch language in Settings (persists to SuperTokens
|
||||
metadata), confirm UI + calendar + SMS behavior.
|
||||
|
||||
# Removing a language
|
||||
|
||||
Reverse of the above: delete from the five locale-set sites (skip Twilio if it
|
||||
was never listed), `rm -rf src/locales/<code>`, drop the dayjs import, then
|
||||
`bun run i18n:check` + tsc. Users with the removed locale saved in metadata
|
||||
fall back to English automatically via `resolveLocale`.
|
||||
|
||||
Fallback semantics: untranslated entries render English (msgid); unauth
|
||||
visitors get Accept-Language detection (SSR first paint on public routes is
|
||||
English, corrected after hydration); authed users get their saved locale
|
||||
server-rendered.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: i18n
|
||||
description: Rules and workflow for user-facing strings — wrapping new text in Lingui macros, updating catalogs, and translating new entries. Use whenever adding, editing, or removing any user-visible text (components, hooks, toasts, route titles, server errors).
|
||||
---
|
||||
|
||||
# i18n workflow (Lingui v6)
|
||||
|
||||
Every user-facing string must go through Lingui. English source text IS the
|
||||
translation key (msgid); catalogs live in `src/locales/<locale>/messages.po`
|
||||
(en, es, de, ja — es is Mexican Spanish). msgstr mirrors msgid in en.po; that
|
||||
is normal for the source locale.
|
||||
|
||||
## Wrapping rules
|
||||
|
||||
Allowed APIs — context-bound only (SSR-safe):
|
||||
|
||||
- `import { Trans, Plural, useLingui } from "@lingui/react/macro"`
|
||||
- `import { msg } from "@lingui/core/macro"` (descriptor only)
|
||||
- NEVER `t`/`plural`/`select` from `@lingui/core/macro` — they bind a global
|
||||
i18n and bleed locale across SSR requests.
|
||||
- Carve-out: a component that only needs `i18n._()` (no `t`` macro) may use
|
||||
`useLingui` from plain `@lingui/react` (see header.tsx, mantine-provider) —
|
||||
it's the same context-bound hook, just without macro sugar.
|
||||
|
||||
Patterns:
|
||||
|
||||
| Context | Pattern |
|
||||
|---|---|
|
||||
| JSX text | `<Trans>Match starts soon</Trans>` |
|
||||
| String props / expressions in a component or hook | `const { t } = useLingui()` at TOP LEVEL (unconditional — React Compiler), then `t`Saved!`` |
|
||||
| Countable text in JSX | `<Plural value={n} one="# point" other="# points" />` |
|
||||
| Countable text in a string expression | `i18n._({ ...msg`{n, plural, one {# team} other {# teams}}`, values: { n } })` — a bare `t`Teams (${n})`` gives translators no plural branch |
|
||||
| Route header titles (loader/beforeLoad) | `title: msg`Manage {name}`, titleValues: { name }` — resolved by `Header` |
|
||||
| Plain .ts utils | take an `i18n: I18n` param and use msg descriptors (see `src/features/predictions/utils.ts`) |
|
||||
| Server code (API routes, push, meta) | `localizedFor(context.metadata)` from `src/lib/i18n/server-messages.ts` (or `createI18n(resolveLocale(...))`) + msg descriptors |
|
||||
| Server-fn error toasts | localized centrally via the ErrorType map in `src/lib/i18n/error-messages.ts` — add new ErrorTypes there, not per-call-site |
|
||||
|
||||
v6 signature: values ride inside the descriptor — `i18n._({ ...msg`Hi {name}`,
|
||||
values: { name } })`. `i18n._(descriptor, values)` does NOT exist in v6.
|
||||
|
||||
Homographs: when the same English word means different things in different
|
||||
places (e.g. "Home" = nav screen vs. home team), give one of them a context so
|
||||
translators get separate entries: `t({ message: "Home", context: "match team" })`
|
||||
— Spanish needs "Inicio" vs "Local" there. Same-spelling msgids silently share
|
||||
one translation otherwise.
|
||||
|
||||
Never wrap: logger/console output, query keys, route paths, URLs, CSS values,
|
||||
PB collection/field names, ids/slugs, `===`-compared values, object keys,
|
||||
brand names FLXN/Flexxon/Spotify, data-derived names, thrown errors that are
|
||||
only logged (mapKnownError discards their text — only the ErrorType map's
|
||||
strings reach users).
|
||||
|
||||
## Architecture invariants (violating these breaks the app, not just a string)
|
||||
|
||||
- **Provider order**: `LinguiProvider` must stay ABOVE `AuthProvider`
|
||||
(`src/features/core/components/providers.tsx`). The localized query hooks
|
||||
(`useServerQuery`/`useMe`/etc.) call `useLingui`, so anything using them —
|
||||
including AuthProvider — must render inside it. LinguiProvider therefore
|
||||
reads the auth query with raw `useQuery`, never `useAuth`/`useMe`.
|
||||
- **`__root` head() must not read `match`/context** — doing so creates
|
||||
circular route-type inference that breaks `beforeLoad` typing in child
|
||||
routes. Meta stays DEFAULT_LOCALE (crawlers have no session anyway).
|
||||
- **Babel order** in vite.config.ts: lingui macro plugin BEFORE
|
||||
react-compiler. Reversed order fails the build on `<Plural>`.
|
||||
- Catalog loading: `__root` beforeLoad preloads the active locale on authed
|
||||
routes; `LinguiProvider` self-loads a missing catalog client-side (public
|
||||
routes). Unauthenticated non-en visitors get an English SSR first paint on
|
||||
/login that corrects after hydration — known, accepted.
|
||||
- `useMemo`/`useCallback` bodies that CACHE a computed string from `t`/`i18n`
|
||||
must list them in deps. (Callbacks that merely *call* `t` when invoked read
|
||||
the live locale and are safe.)
|
||||
- Tab labels: `SwipeableTabs` items need a stable `value` slug; `label` is
|
||||
the translated display string.
|
||||
|
||||
## Catalog workflow — run after any string change
|
||||
|
||||
1. `bun run extract` — regenerates ALL locale .po files from source, adds new
|
||||
msgids, removes stale ones. Never hand-edit msgids or en.po.
|
||||
2. Translate the new empty `msgstr ""` entries in es/de/ja. Spawn one
|
||||
translation agent per locale (batch ~60 entries; have them RETURN the
|
||||
translated entries and merge by msgid — don't let parallel agents edit one
|
||||
file). Translation rules for agents:
|
||||
- msgid byte-identical; translate msgstr only, single line.
|
||||
- Preserve ICU placeholders/plural structure (translate only the words
|
||||
inside branches) and `<0>`/`</0>` tags exactly.
|
||||
- Watch invisible characters: msgids can contain non-breaking spaces
|
||||
(\xa0) that agents silently normalize — verify by byte comparison,
|
||||
not visual.
|
||||
- Registers: es = informal tú (Mexican), de = informal du, ja =
|
||||
polite-casual です/ます. FLXN/Flexxon/Spotify untranslated.
|
||||
3. `bun run i18n:check` — fails if any locale has missing translations. CI
|
||||
(`.gitea/workflows/ci-cd.yaml`, `i18n-check` job) enforces this AND fails
|
||||
if committed catalogs are stale vs. source — always commit `src/locales`
|
||||
with string changes.
|
||||
4. `bun run build` runs extract automatically, so a forgotten extract can't
|
||||
ship hash-ids — but only step 3 catches untranslated entries.
|
||||
|
||||
Production strips English defaults from components; text lives only in the
|
||||
hash-keyed compiled catalog, so a msgid missing from the catalog renders as
|
||||
its hash. Non-en catalogs are separate lazy chunks — English users download
|
||||
nothing extra.
|
||||
@@ -7,9 +7,38 @@ on:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
i18n-check:
|
||||
name: i18n Catalog Check
|
||||
runs-on: ubuntu-latest
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Bun
|
||||
run: |
|
||||
npm install -g bun
|
||||
bun --version
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Catalogs in sync with source
|
||||
run: |
|
||||
git config --global --add safe.directory "$(pwd)"
|
||||
bunx lingui extract --clean
|
||||
git diff --exit-code src/locales || {
|
||||
echo "::error::Locale catalogs are stale — run 'bun run extract' and commit src/locales"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: No missing translations
|
||||
run: bunx lingui compile --strict
|
||||
|
||||
build-app:
|
||||
name: Build and Push App Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: i18n-check
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -21,3 +21,5 @@ yarn.lock
|
||||
/pb_data/
|
||||
/.tanstack/
|
||||
/dist/
|
||||
# lingui compile output (i18n:check); runtime uses .po via vite plugin
|
||||
src/locales/*/messages.js
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# FLXN
|
||||
|
||||
## i18n (Lingui v6) — applies to every user-facing string
|
||||
|
||||
Any change that adds, edits, or removes user-visible text must follow the
|
||||
`i18n` skill (`.claude/skills/i18n/SKILL.md`): wrap with context-bound Lingui
|
||||
macros, then `bun run extract`, translate new entries in every non-`en`
|
||||
locale, and `bun run i18n:check`. Adding a language: use the `add-language`
|
||||
skill.
|
||||
|
||||
Hard rules (full detail in the skill):
|
||||
|
||||
- Only `Trans`/`Plural`/`useLingui` from `@lingui/react/macro` and `msg` from
|
||||
`@lingui/core/macro`. Never global `t`/`plural`/`select` from core/macro
|
||||
(SSR locale bleed).
|
||||
- `useLingui()` at top level of components/hooks only; add `t`/`i18n` to memo
|
||||
dep arrays.
|
||||
- Never hand-edit msgids or `src/locales/en/messages.po` (generated).
|
||||
- vite.config.ts babel plugin order (lingui before react-compiler) is
|
||||
load-bearing.
|
||||
- `LinguiProvider` stays ABOVE `AuthProvider` in providers.tsx (localized
|
||||
query hooks call useLingui), and `__root`'s `head()` must never read
|
||||
`match`/context (circular route-type inference breaks beforeLoad typing).
|
||||
@@ -0,0 +1,78 @@
|
||||
# FLXN Replay — Design Spec (v1, locked)
|
||||
|
||||
**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork).
|
||||
|
||||
**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store.
|
||||
|
||||
## Availability
|
||||
- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`).
|
||||
- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap.
|
||||
- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end.
|
||||
|
||||
## Data sources (all existing)
|
||||
- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`).
|
||||
- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record).
|
||||
- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = "<id>"`.
|
||||
- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly.
|
||||
- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions.
|
||||
|
||||
## Player recap (content + derivation)
|
||||
All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id):
|
||||
- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches.
|
||||
- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`.
|
||||
- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent.
|
||||
- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins.
|
||||
- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential.
|
||||
- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games.
|
||||
- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record.
|
||||
- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank.
|
||||
- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together".
|
||||
- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit.
|
||||
- **Most-reacted match** — their match with the most reaction rows; include the top emoji.
|
||||
- **Badges earned this event** — see open questions; v1 approach below.
|
||||
|
||||
## Overall recap (content + derivation)
|
||||
- **Champion + podium** — existing `first/second/third_place` derivation.
|
||||
- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap.
|
||||
- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set).
|
||||
- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match.
|
||||
- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods).
|
||||
- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable).
|
||||
- **Most-reacted moment** — match with most reactions overall + top emoji.
|
||||
- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted.
|
||||
- **Team count / attendance** — `tournament.teams.length` / unique players across rosters.
|
||||
|
||||
## Share-card layout
|
||||
- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components.
|
||||
- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows.
|
||||
- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable.
|
||||
|
||||
## Entry points
|
||||
- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap.
|
||||
|
||||
## Reuses / dependencies
|
||||
- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`.
|
||||
- No dependency on any Wave 2/3 feature; ships standalone.
|
||||
|
||||
## Implementation notes for a fresh agent (build this cold)
|
||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then:
|
||||
- **No migration.** Replay writes nothing. Do not add a collection.
|
||||
- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`.
|
||||
- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`).
|
||||
- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`.
|
||||
- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring.
|
||||
- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty.
|
||||
- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`).
|
||||
- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine.
|
||||
- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file).
|
||||
- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks.
|
||||
- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary.
|
||||
|
||||
## Tunables / open questions
|
||||
- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building.
|
||||
- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later?
|
||||
- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own.
|
||||
- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown.
|
||||
- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output.
|
||||
- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later.
|
||||
- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Free-Agent Matchmaking Board — Design Spec (v1, locked)
|
||||
|
||||
**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.
|
||||
|
||||
Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.
|
||||
|
||||
## What exists today (baseline)
|
||||
- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept.
|
||||
- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`.
|
||||
- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`.
|
||||
- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
|
||||
- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players.
|
||||
- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes.
|
||||
|
||||
## The board
|
||||
Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:
|
||||
- `PlayerAvatar` + name.
|
||||
- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34).
|
||||
- **Badges row:** the player's earned badges, compact (icon row, overflow "+N").
|
||||
- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state.
|
||||
- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`).
|
||||
|
||||
Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1).
|
||||
|
||||
## The handshake
|
||||
1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = <B's row id>`. A can have **one outgoing claim** at a time (withdraw to switch).
|
||||
2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest.
|
||||
3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow.
|
||||
4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.)
|
||||
5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report.
|
||||
|
||||
Team naming: default `"<A first> & <B first>"`, same shape the random generator produces; rename later via the existing team-edit flow.
|
||||
|
||||
## Data model (decided: Option A — field on `free_agents`)
|
||||
Two options were weighed:
|
||||
|
||||
- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = <my row id>`; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
|
||||
- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".
|
||||
|
||||
`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me".
|
||||
|
||||
## Server fns (new, in `src/features/tournaments/server.ts`)
|
||||
All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`.
|
||||
|
||||
- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target.
|
||||
- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`.
|
||||
- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer.
|
||||
|
||||
Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).
|
||||
|
||||
## Realtime + push
|
||||
- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land.
|
||||
- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`):
|
||||
- On claim → target: `{ title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }`.
|
||||
- On accept → claimer: `{ title: "It's a match", body: "<Name> accepted — you're teammates", ... }`.
|
||||
- Decline: **no push** (avoid rejection stings; the cleared state shows on the board).
|
||||
|
||||
## Regional visibility gate (relaxed)
|
||||
Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).
|
||||
|
||||
## Admin fallback interplay
|
||||
- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains.
|
||||
- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle.
|
||||
- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query.
|
||||
|
||||
## Design system
|
||||
- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors.
|
||||
- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel).
|
||||
- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
|
||||
- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip.
|
||||
|
||||
## Implementation notes for a fresh agent (build this cold)
|
||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then:
|
||||
- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`.
|
||||
- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = "<rowId>"' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`.
|
||||
- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request.
|
||||
- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix.
|
||||
- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`.
|
||||
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
|
||||
- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
|
||||
- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look.
|
||||
- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.
|
||||
|
||||
## Tunables / open questions
|
||||
- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
|
||||
- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`.
|
||||
- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
|
||||
- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
|
||||
- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
|
||||
- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
|
||||
- **Push copy/tag collapsing:** `tag: "partner-claim-<agentId>"` collapses repeat claims from the same person; revisit if it feels spammy.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Push Notifications — Ideas Board (nothing decided)
|
||||
|
||||
**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to.
|
||||
|
||||
## Guiding principle (the one non-negotiable)
|
||||
|
||||
**Minimal by default. Strictly opt-in. Do not annoy.**
|
||||
|
||||
- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low.
|
||||
- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back.
|
||||
- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now.
|
||||
- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist.
|
||||
|
||||
## What already exists (the foundation)
|
||||
|
||||
- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**.
|
||||
- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`.
|
||||
- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom).
|
||||
- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened.
|
||||
|
||||
Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be.
|
||||
|
||||
## The menu
|
||||
|
||||
Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch.
|
||||
|
||||
### BEFORE the tournament
|
||||
|
||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") |
|
||||
| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off |
|
||||
| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** |
|
||||
| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off |
|
||||
| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off |
|
||||
| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off |
|
||||
| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) |
|
||||
|
||||
### DURING the tournament
|
||||
|
||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** |
|
||||
| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast |
|
||||
| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off |
|
||||
| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only |
|
||||
| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever |
|
||||
| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off |
|
||||
| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) |
|
||||
|
||||
### AFTER the tournament
|
||||
|
||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) |
|
||||
| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later |
|
||||
| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off |
|
||||
| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off |
|
||||
| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate |
|
||||
|
||||
### AMBIENT / cross-tournament
|
||||
|
||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist |
|
||||
| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") |
|
||||
| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off |
|
||||
| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building |
|
||||
|
||||
## Suggested tiny default-on set (opinion, not decision)
|
||||
|
||||
If a user enables notifications at all, they get exactly two things until they say otherwise:
|
||||
|
||||
1. **You're up next** — `startMatch` seam.
|
||||
2. **You were claimed as a partner** — free-agent assignment seam.
|
||||
|
||||
Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment.
|
||||
|
||||
## How to wire one (when the picks are made)
|
||||
|
||||
The pattern is deliberately boring:
|
||||
|
||||
1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match.
|
||||
2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind.
|
||||
3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation.
|
||||
|
||||
## Follow-up idea: per-type preferences
|
||||
|
||||
Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Side Bets — Design Spec (v1, locked)
|
||||
|
||||
**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
|
||||
|
||||
No real money, ever. This is an in-tournament engagement layer.
|
||||
|
||||
## Currency
|
||||
- Each player gets **10 🪙 tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
|
||||
- **Whole numbers only.**
|
||||
- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
|
||||
|
||||
## When you can bet
|
||||
- A match's book **opens when the match is *set*** (both teams populated → status `ready`).
|
||||
- The book **closes the instant the admin *starts* the match** (status `started`) — same moment walkouts/announcements play. No bets after start.
|
||||
- This dovetails with score reporting, which only begins once a match is `started`.
|
||||
|
||||
## Bet type 1 — Winner (social pool / parimutuel)
|
||||
The core, social bet. You bet against your friends, not the house.
|
||||
|
||||
- Pick the winning team. Stake `1..(your tokens)`.
|
||||
- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
|
||||
- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
|
||||
- **House floor:** a correct bet always returns **at least ~1.5× the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
|
||||
- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
|
||||
- **Edge cases:**
|
||||
- Everyone picked the same team → no losers → **all stakes refunded** (no action).
|
||||
- Nobody picked the winner → **everyone refunded**.
|
||||
- Solo bettor → the **house floor** applies (this is exactly what it's for).
|
||||
- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
|
||||
|
||||
## Bet type 2 — Overtime long shots (house-backed, fixed payout)
|
||||
NOT a pool — you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
|
||||
|
||||
| Bet | Wins if | Payout |
|
||||
|---|---|---|
|
||||
| Reaches OT | `ot_count ≥ 1` | **2×** |
|
||||
| Reaches Double OT | `ot_count ≥ 2` | **4×** |
|
||||
| Reaches Triple OT | `ot_count ≥ 3` | **8×** |
|
||||
|
||||
Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
|
||||
|
||||
## Leaderboard & payoff
|
||||
- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
|
||||
- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
|
||||
|
||||
## Data model (proposed — finalize at build)
|
||||
- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
|
||||
- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
|
||||
- Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
|
||||
|
||||
## UI/UX
|
||||
- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
|
||||
- **Bet slip sheet:** choose market, stake slider, live projected payout.
|
||||
- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
|
||||
- **Side Bets leaderboard screen** — mirror the existing predictions leaderboard component.
|
||||
|
||||
## Reuses / dependencies
|
||||
- SSE bus, the predictions leaderboard pattern, the badges system.
|
||||
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
|
||||
|
||||
## Side Bets on the home screen (reserved slot)
|
||||
The Wave 2 home redesign **leaves a labeled slot for this** — build it here later:
|
||||
- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
|
||||
- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start.
|
||||
- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
|
||||
- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT — see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
|
||||
|
||||
## Implementation notes for a fresh agent (build this cold)
|
||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
|
||||
- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` — copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
|
||||
- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path — admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
|
||||
- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
|
||||
- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection — plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
|
||||
- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
|
||||
- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
|
||||
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only.
|
||||
- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` — keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
|
||||
- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
|
||||
|
||||
## Tunables to revisit after first live use
|
||||
- House floor multiplier (default **1.5×**).
|
||||
- OT multipliers (**2× / 4× / 8×**).
|
||||
- Comeback token amount (**1**).
|
||||
- Whether an all-agreed pool refunds or voids.
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
app: flxn
|
||||
component: app
|
||||
spec:
|
||||
replicas: 1
|
||||
replicas: 1 # Must stay at 1 for SSE
|
||||
selector:
|
||||
matchLabels:
|
||||
app: flxn
|
||||
@@ -55,6 +55,12 @@ spec:
|
||||
value: "3000"
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: TELEMETRY_EXCLUDE_PHONES
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: flxn-secrets
|
||||
key: telemetry_exclude_phones
|
||||
optional: true
|
||||
|
||||
- name: TWILIO_ACCOUNT_SID
|
||||
valueFrom:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "@lingui/conf";
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: "en",
|
||||
locales: ["en", "es", "de", "ja"],
|
||||
catalogs: [
|
||||
{
|
||||
path: "<rootDir>/src/locales/{locale}/messages",
|
||||
include: ["src"],
|
||||
exclude: ["**/node_modules/**", "**/*.d.ts"],
|
||||
},
|
||||
],
|
||||
});
|
||||
+23
-7
@@ -1,15 +1,24 @@
|
||||
{
|
||||
"name": "tanstack-start-example-basic-react-query",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"sideEffects": [
|
||||
"**/*.css",
|
||||
"**/themes/styles.ts",
|
||||
"**/themes/theme-styles.ts"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||
"start": "bun run server.ts"
|
||||
"extract": "lingui extract --clean",
|
||||
"i18n:check": "lingui extract --clean && lingui compile --strict",
|
||||
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||
"start": "bun run server.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@lingui/core": "^6.5.0",
|
||||
"@lingui/react": "^6.5.0",
|
||||
"@mantine/carousel": "^8.2.4",
|
||||
"@mantine/core": "^8.2.4",
|
||||
"@mantine/dates": "^8.2.4",
|
||||
@@ -34,8 +43,8 @@
|
||||
"facehash": "^0.0.7",
|
||||
"framer-motion": "^12.23.12",
|
||||
"pocketbase": "^0.26.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"redaxios": "^0.5.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -43,12 +52,18 @@
|
||||
"supertokens-web-js": "^0.15.0",
|
||||
"twilio": "^5.8.0",
|
||||
"vaul": "^1.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"web-vitals": "^6.2.0",
|
||||
"zod": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.5.0",
|
||||
"@lingui/cli": "^6.5.0",
|
||||
"@lingui/vite-plugin": "^6.5.0",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
@@ -60,6 +75,7 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
// Rewrites the player stats views from a cartesian LIKE-join over
|
||||
// players x teams x matches (which forced a full re-scan per row and took
|
||||
// ~1.5-2s per request) to equi-joins driven by matches, with team rosters
|
||||
// expanded once via json_each. Results are byte-identical; each view now
|
||||
// runs in well under 100ms. The unary "+" in the regional filter prevents
|
||||
// the query planner from picking a pathological join order.
|
||||
migrate((app) => {
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended'\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND (tour.regional = false OR tour.regional IS NULL)\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(mt.match_id) as matches,\n COUNT(DISTINCT mt.tournament) as tournaments,\n SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) as wins,\n SUM(CASE WHEN mt.cups_for < mt.cups_against THEN 1 ELSE 0 END) as losses,\n SUM(mt.cups_for) as total_cups_made,\n SUM(mt.cups_against) as total_cups_against,\n ROUND((CAST(SUM(CASE WHEN mt.cups_for > mt.cups_against THEN 1 ELSE 0 END) AS REAL) / COUNT(mt.match_id)) * 100, 2) as win_percentage,\n ROUND(CAST(SUM(mt.cups_for) AS REAL) / COUNT(mt.match_id), 2) as avg_cups_per_match,\n ROUND(AVG(CASE WHEN mt.cups_for > mt.cups_against THEN mt.cups_for - mt.cups_against ELSE NULL END), 2) as margin_of_victory,\n ROUND(AVG(CASE WHEN mt.cups_for < mt.cups_against THEN mt.cups_against - mt.cups_for ELSE NULL END), 2) as margin_of_loss\n FROM (\n SELECT m.id as match_id, m.tournament as tournament, m.home as team_id, m.home_cups as cups_for, m.away_cups as cups_against\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n UNION ALL\n SELECT m.id, m.tournament, m.away, m.away_cups, m.home_cups\n FROM matches m\n JOIN tournaments tour ON m.tournament = tour.id\n WHERE m.status = 'ended' AND +tour.regional = true\n ) mt\n JOIN (\n SELECT teams.id AS team_id, je.value AS player_id\n FROM teams, json_each(ifnull(nullif(teams.players, ''), '[]')) je\n GROUP BY teams.id, je.value\n ) tp ON tp.team_id = mt.team_id\n JOIN players p ON p.id = tp.player_id\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
}, (app) => {
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended'\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_mainline_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n (tour.regional = false OR tour.regional IS NULL)\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
|
||||
{
|
||||
const collection = app.findCollectionByNameOrId("player_regional_stats");
|
||||
unmarshal({
|
||||
"viewQuery": "SELECT\n p.id as id,\n p.id as player_id,\n (p.first_name || ' ' || p.last_name) as player_name,\n COUNT(m.id) as matches,\n COUNT(DISTINCT m.tournament) as tournaments,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) as wins,\n SUM(CASE\n WHEN (m.home = t.id AND m.home_cups < m.away_cups) OR\n (m.away = t.id AND m.away_cups < m.home_cups)\n THEN 1 ELSE 0\n END) as losses,\n SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) as total_cups_made,\n SUM(CASE\n WHEN m.home = t.id THEN m.away_cups\n WHEN m.away = t.id THEN m.home_cups\n ELSE 0\n END) as total_cups_against,\n -- Win percentage\n ROUND((CAST(SUM(CASE\n WHEN (m.home = t.id AND m.home_cups > m.away_cups) OR\n (m.away = t.id AND m.away_cups > m.home_cups)\n THEN 1 ELSE 0\n END) AS REAL) / COUNT(m.id)) * 100, 2) as win_percentage,\n -- Average cups per match\n ROUND(CAST(SUM(CASE\n WHEN m.home = t.id THEN m.home_cups\n WHEN m.away = t.id THEN m.away_cups\n ELSE 0\n END) AS REAL) / COUNT(m.id), 2) as avg_cups_per_match,\n -- Margin of Victory\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups > m.away_cups\n THEN m.home_cups - m.away_cups\n WHEN m.away = t.id AND m.away_cups > m.home_cups\n THEN m.away_cups - m.home_cups\n ELSE NULL\n END), 2) as margin_of_victory,\n -- Margin of Loss\n ROUND(AVG(CASE\n WHEN m.home = t.id AND m.home_cups < m.away_cups\n THEN m.away_cups - m.home_cups\n WHEN m.away = t.id AND m.away_cups < m.home_cups\n THEN m.home_cups - m.away_cups\n ELSE NULL\n END), 2) as margin_of_loss\n FROM players p, teams t, matches m, tournaments tour\n WHERE\n t.players LIKE '%\"' || p.id || '\"%' AND\n (m.home = t.id OR m.away = t.id) AND\n m.tournament = tour.id AND\n m.status = 'ended' AND\n tour.regional = true\n GROUP BY p.id"
|
||||
}, collection);
|
||||
app.save(collection);
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
/// <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"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_340646327",
|
||||
"hidden": false,
|
||||
"id": "relation3177167065",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "tournament",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation2551806565",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "json2153001328",
|
||||
"maxSize": 0,
|
||||
"name": "picks",
|
||||
"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_1784007613",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_predictions_tournament_player` ON `predictions` (`tournament`, `player`)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "predictions",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1784007613");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(24, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9101010101",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_home_cups",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(25, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9202020202",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_away_cups",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(26, new Field({
|
||||
"hidden": false,
|
||||
"id": "number9303030303",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "reported_ot_count",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(27, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_1568971955",
|
||||
"hidden": false,
|
||||
"id": "relation9404040404",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "reported_by_team",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(28, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation9505050505",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "reported_by_player",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9101010101")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9202020202")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number9303030303")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation9404040404")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation9505050505")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/// <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"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "pbc_3072146508",
|
||||
"hidden": false,
|
||||
"id": "relation_player_push",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "player",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_endpoint_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "endpoint",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_p256dh_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "p256dh",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_auth_push",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "auth",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text_user_agent_push",
|
||||
"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_push_subscriptions",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_push_subscriptions_endpoint` ON `push_subscriptions` (`endpoint`)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "push_subscriptions",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_push_subscriptions");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
// Dedupe before adding the unique index: keep the best row per
|
||||
// (player, badge) — earned first, then highest progress, then newest.
|
||||
app.db().newQuery(`
|
||||
DELETE FROM badge_progress
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY player, badge
|
||||
ORDER BY earned DESC, progress DESC, updated DESC
|
||||
) AS rn
|
||||
FROM badge_progress
|
||||
)
|
||||
WHERE rn = 1
|
||||
)
|
||||
`).execute()
|
||||
|
||||
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||
|
||||
unmarshal({
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX `idx_badge_progress_player_badge` ON `badge_progress` (`player`, `badge`)"
|
||||
]
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1063824264")
|
||||
|
||||
unmarshal({
|
||||
"indexes": []
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -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"));
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Hand-written Web Push handlers, imported into the Workbox-generated SW via
|
||||
// `importScripts` (see scripts/generate-sw.mjs). Workbox never overwrites this
|
||||
// file — it's copied verbatim from /public and pulled in at SW startup.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch (e) {
|
||||
data = { title: 'Notification', body: event.data ? event.data.text() : '' };
|
||||
}
|
||||
|
||||
const title = data.title || 'Flexxon';
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/icon-192x192.png',
|
||||
badge: '/icon-192x192.png',
|
||||
tag: data.tag || undefined,
|
||||
data: { url: data.url || '/' },
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const targetUrl = (event.notification.data && event.notification.data.url) || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Focus an existing tab if one is already open, else open a new one.
|
||||
for (const client of clientList) {
|
||||
try {
|
||||
const clientUrl = new URL(client.url);
|
||||
const target = new URL(targetUrl, self.location.origin);
|
||||
if (clientUrl.pathname === target.pathname && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed URLs
|
||||
}
|
||||
}
|
||||
for (const client of clientList) {
|
||||
if ('focus' in client) {
|
||||
client.navigate(targetUrl);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(targetUrl);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -13,8 +13,13 @@ const { count, size, warnings } = await generateSW({
|
||||
'icon-192x192.png',
|
||||
'icon-512x512.png',
|
||||
'site.webmanifest',
|
||||
'styles.css',
|
||||
'push-handlers.js',
|
||||
],
|
||||
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into
|
||||
// the generated SW at startup. importScripts runs them in the SW global
|
||||
// scope, so their `push` / `notificationclick` listeners compose with
|
||||
// Workbox's precache + runtime caching setup below.
|
||||
importScripts: ['/push-handlers.js'],
|
||||
navigateFallback: null,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Grant or revoke a role for a user by phone number (US +1 assumed).
|
||||
// Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]
|
||||
import "dotenv/config";
|
||||
import SuperTokens from "supertokens-node";
|
||||
import Session from "supertokens-node/recipe/session";
|
||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||
import UserRoles from "supertokens-node/recipe/userroles";
|
||||
|
||||
SuperTokens.init({
|
||||
framework: "custom",
|
||||
supertokens: {
|
||||
connectionURI: process.env.SUPERTOKENS_URI || "http://localhost:3567",
|
||||
apiKey: process.env.SUPERTOKENS_API_KEY || undefined,
|
||||
},
|
||||
appInfo: {
|
||||
appName: "FLXN",
|
||||
apiDomain: "http://localhost:3000",
|
||||
websiteDomain: "http://localhost:3000",
|
||||
apiBasePath: "/api/auth",
|
||||
websiteBasePath: "/auth",
|
||||
},
|
||||
recipeList: [
|
||||
Passwordless.init({ contactMethod: "PHONE", flowType: "USER_INPUT_CODE" }),
|
||||
Session.init(),
|
||||
UserRoles.init(),
|
||||
],
|
||||
});
|
||||
|
||||
const VALID_ROLES = ["Admin", "Developer"] as const;
|
||||
|
||||
const raw = process.argv[2];
|
||||
const revoke = process.argv.includes("--revoke");
|
||||
const roleFlagIndex = process.argv.indexOf("--role");
|
||||
const role = roleFlagIndex === -1 ? "Admin" : process.argv[roleFlagIndex + 1];
|
||||
|
||||
if (!raw) {
|
||||
console.error("Usage: bun run scripts/make-admin.ts <phone> [--role Admin|Developer] [--revoke]");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) {
|
||||
console.error(`Invalid role "${role}". Valid roles: ${VALID_ROLES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const digits = raw.replace(/[^\d]/g, "");
|
||||
const candidates = Array.from(
|
||||
new Set([
|
||||
raw.startsWith("+") ? raw : null,
|
||||
digits.length === 10 ? `+1${digits}` : null,
|
||||
`+${digits}`,
|
||||
digits,
|
||||
].filter(Boolean) as string[])
|
||||
);
|
||||
|
||||
let user: { id: string } | undefined;
|
||||
let matched = "";
|
||||
for (const phoneNumber of candidates) {
|
||||
const users = await SuperTokens.listUsersByAccountInfo("public", { phoneNumber });
|
||||
if (users.length) {
|
||||
user = users[0];
|
||||
matched = phoneNumber;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
console.error(`No SuperTokens user found for phone (tried: ${candidates.join(", ")}).`);
|
||||
console.error("The user must have logged in at least once so their account exists.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await UserRoles.createNewRoleOrAddPermissions(role, []);
|
||||
|
||||
if (revoke) {
|
||||
const res = await UserRoles.removeUserRole("public", user.id, role);
|
||||
console.log(`Removed ${role} from ${matched} (user ${user.id}):`, res.status);
|
||||
} else {
|
||||
const res = await UserRoles.addRoleToUser("public", user.id, role);
|
||||
console.log(
|
||||
`Granted ${role} to ${matched} (user ${user.id}):`,
|
||||
res.status === "OK"
|
||||
? res.didUserAlreadyHaveRole
|
||||
? "already had it"
|
||||
: "added"
|
||||
: res.status
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Note: sign out and back in (or refresh the session) for the role to take effect.");
|
||||
process.exit(0);
|
||||
+337
-1
@@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
|
||||
import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon'
|
||||
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
|
||||
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
||||
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
|
||||
@@ -29,21 +30,35 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
|
||||
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||
import { Route as ApiPushUnsubscribeRouteImport } from './routes/api/push/unsubscribe'
|
||||
import { Route as ApiPushTestRouteImport } from './routes/api/push/test'
|
||||
import { Route as ApiPushSubscribeRouteImport } from './routes/api/push/subscribe'
|
||||
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||
import { Route as AuthedAdminTelemetryRouteImport } from './routes/_authed/admin/telemetry'
|
||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||
import { Route as AuthedAdminTelemetryIndexRouteImport } from './routes/_authed/admin/telemetry/index'
|
||||
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
|
||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||
import { Route as AuthedAdminTelemetryUsageRouteImport } from './routes/_authed/admin/telemetry/usage'
|
||||
import { Route as AuthedAdminTelemetrySessionsRouteImport } from './routes/_authed/admin/telemetry/sessions'
|
||||
import { Route as AuthedAdminTelemetryPlayersRouteImport } from './routes/_authed/admin/telemetry/players'
|
||||
import { Route as AuthedAdminTelemetryFunctionsRouteImport } from './routes/_authed/admin/telemetry/functions'
|
||||
import { Route as AuthedAdminTelemetryErrorsRouteImport } from './routes/_authed/admin/telemetry/errors'
|
||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
|
||||
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
|
||||
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||
import { Route as AuthedAdminTournamentsIdEnrollmentRouteImport } from './routes/_authed/admin/tournaments/$id/enrollment'
|
||||
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
|
||||
|
||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||
@@ -111,6 +126,11 @@ const ApiTournamentsUploadLogoRoute =
|
||||
path: '/api/tournaments/upload-logo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
|
||||
id: '/api/telemetry/beacon',
|
||||
path: '/api/telemetry/beacon',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
||||
id: '/api/teams/upload-logo',
|
||||
path: '/api/teams/upload-logo',
|
||||
@@ -146,6 +166,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
||||
path: '/api/spotify/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushUnsubscribeRoute = ApiPushUnsubscribeRouteImport.update({
|
||||
id: '/api/push/unsubscribe',
|
||||
path: '/api/push/unsubscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushTestRoute = ApiPushTestRouteImport.update({
|
||||
id: '/api/push/test',
|
||||
path: '/api/push/test',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPushSubscribeRoute = ApiPushSubscribeRouteImport.update({
|
||||
id: '/api/push/subscribe',
|
||||
path: '/api/push/subscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||
id: '/api/events/$',
|
||||
path: '/api/events/$',
|
||||
@@ -172,6 +207,11 @@ const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
|
||||
path: '/profile/$playerId',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryRoute = AuthedAdminTelemetryRouteImport.update({
|
||||
id: '/telemetry',
|
||||
path: '/telemetry',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
||||
id: '/preview',
|
||||
path: '/preview',
|
||||
@@ -193,6 +233,18 @@ const AuthedAdminTournamentsIndexRoute =
|
||||
path: '/tournaments/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryIndexRoute =
|
||||
AuthedAdminTelemetryIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsRoute =
|
||||
AuthedTournamentsIdPredictionsRouteImport.update({
|
||||
id: '/tournaments/$id/predictions',
|
||||
path: '/tournaments/$id/predictions',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdGroupsRoute =
|
||||
AuthedTournamentsIdGroupsRouteImport.update({
|
||||
id: '/tournaments/$id/groups',
|
||||
@@ -205,6 +257,36 @@ const AuthedTournamentsIdBracketRoute =
|
||||
path: '/tournaments/$id/bracket',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryUsageRoute =
|
||||
AuthedAdminTelemetryUsageRouteImport.update({
|
||||
id: '/usage',
|
||||
path: '/usage',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetrySessionsRoute =
|
||||
AuthedAdminTelemetrySessionsRouteImport.update({
|
||||
id: '/sessions',
|
||||
path: '/sessions',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryPlayersRoute =
|
||||
AuthedAdminTelemetryPlayersRouteImport.update({
|
||||
id: '/players',
|
||||
path: '/players',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryFunctionsRoute =
|
||||
AuthedAdminTelemetryFunctionsRouteImport.update({
|
||||
id: '/functions',
|
||||
path: '/functions',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTelemetryErrorsRoute =
|
||||
AuthedAdminTelemetryErrorsRouteImport.update({
|
||||
id: '/errors',
|
||||
path: '/errors',
|
||||
getParentRoute: () => AuthedAdminTelemetryRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdIndexRoute =
|
||||
AuthedAdminTournamentsIdIndexRouteImport.update({
|
||||
id: '/tournaments/$id/',
|
||||
@@ -217,6 +299,18 @@ const ApiFilesCollectionRecordIdFileRoute =
|
||||
path: '/api/files/$collection/$recordId/$file',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsMakeRoute =
|
||||
AuthedTournamentsIdPredictionsMakeRouteImport.update({
|
||||
id: '/tournaments/$id/predictions_/make',
|
||||
path: '/tournaments/$id/predictions/make',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdPredictionsPlayerIdRoute =
|
||||
AuthedTournamentsIdPredictionsPlayerIdRouteImport.update({
|
||||
id: '/tournaments/$id/predictions_/$playerId',
|
||||
path: '/tournaments/$id/predictions/$playerId',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsRunIdRoute =
|
||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||
id: '/tournaments/run/$id',
|
||||
@@ -229,6 +323,12 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
||||
path: '/tournaments/$id/teams',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdEnrollmentRoute =
|
||||
AuthedAdminTournamentsIdEnrollmentRouteImport.update({
|
||||
id: '/tournaments/$id/enrollment',
|
||||
path: '/tournaments/$id/enrollment',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdAssignPartnersRoute =
|
||||
AuthedAdminTournamentsIdAssignPartnersRouteImport.update({
|
||||
id: '/tournaments/$id/assign-partners',
|
||||
@@ -249,11 +349,15 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||
'/admin/badges': typeof AuthedAdminBadgesRoute
|
||||
'/admin/preview': typeof AuthedAdminPreviewRoute
|
||||
'/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -261,15 +365,26 @@ export interface FileRoutesByFullPath {
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin/': typeof AuthedAdminIndexRoute
|
||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
|
||||
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -290,6 +405,9 @@ export interface FileRoutesByTo {
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -297,15 +415,26 @@ export interface FileRoutesByTo {
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
|
||||
'/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/admin/telemetry': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -324,11 +453,15 @@ export interface FileRoutesById {
|
||||
'/_authed/admin/activities': typeof AuthedAdminActivitiesRoute
|
||||
'/_authed/admin/badges': typeof AuthedAdminBadgesRoute
|
||||
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
|
||||
'/_authed/admin/telemetry': typeof AuthedAdminTelemetryRouteWithChildren
|
||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/push/subscribe': typeof ApiPushSubscribeRoute
|
||||
'/api/push/test': typeof ApiPushTestRoute
|
||||
'/api/push/unsubscribe': typeof ApiPushUnsubscribeRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
@@ -336,15 +469,26 @@ export interface FileRoutesById {
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/admin/telemetry/errors': typeof AuthedAdminTelemetryErrorsRoute
|
||||
'/_authed/admin/telemetry/functions': typeof AuthedAdminTelemetryFunctionsRoute
|
||||
'/_authed/admin/telemetry/players': typeof AuthedAdminTelemetryPlayersRoute
|
||||
'/_authed/admin/telemetry/sessions': typeof AuthedAdminTelemetrySessionsRoute
|
||||
'/_authed/admin/telemetry/usage': typeof AuthedAdminTelemetryUsageRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
|
||||
'/_authed/admin/telemetry/': typeof AuthedAdminTelemetryIndexRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/_authed/admin/tournaments/$id/enrollment': typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
@@ -363,11 +507,15 @@ export interface FileRouteTypes {
|
||||
| '/admin/activities'
|
||||
| '/admin/badges'
|
||||
| '/admin/preview'
|
||||
| '/admin/telemetry'
|
||||
| '/profile/$playerId'
|
||||
| '/teams/$teamId'
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -375,15 +523,26 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/telemetry/beacon'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin/'
|
||||
| '/tournaments/'
|
||||
| '/admin/telemetry/errors'
|
||||
| '/admin/telemetry/functions'
|
||||
| '/admin/telemetry/players'
|
||||
| '/admin/telemetry/sessions'
|
||||
| '/admin/telemetry/usage'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/telemetry/'
|
||||
| '/admin/tournaments/'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/enrollment'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
| '/tournaments/$id/predictions/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -404,6 +563,9 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -411,15 +573,26 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/telemetry/beacon'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin'
|
||||
| '/tournaments'
|
||||
| '/admin/telemetry/errors'
|
||||
| '/admin/telemetry/functions'
|
||||
| '/admin/telemetry/players'
|
||||
| '/admin/telemetry/sessions'
|
||||
| '/admin/telemetry/usage'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/tournaments/$id/predictions'
|
||||
| '/admin/telemetry'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/enrollment'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/tournaments/$id/predictions/$playerId'
|
||||
| '/tournaments/$id/predictions/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id'
|
||||
id:
|
||||
@@ -437,11 +610,15 @@ export interface FileRouteTypes {
|
||||
| '/_authed/admin/activities'
|
||||
| '/_authed/admin/badges'
|
||||
| '/_authed/admin/preview'
|
||||
| '/_authed/admin/telemetry'
|
||||
| '/_authed/profile/$playerId'
|
||||
| '/_authed/teams/$teamId'
|
||||
| '/_authed/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/push/subscribe'
|
||||
| '/api/push/test'
|
||||
| '/api/push/unsubscribe'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
@@ -449,15 +626,26 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/telemetry/beacon'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/_authed/admin/'
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/admin/telemetry/errors'
|
||||
| '/_authed/admin/telemetry/functions'
|
||||
| '/_authed/admin/telemetry/players'
|
||||
| '/_authed/admin/telemetry/sessions'
|
||||
| '/_authed/admin/telemetry/usage'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/tournaments/$id/groups'
|
||||
| '/_authed/tournaments/$id/predictions'
|
||||
| '/_authed/admin/telemetry/'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||
| '/_authed/admin/tournaments/$id/enrollment'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
| '/_authed/admin/tournaments/run/$id'
|
||||
| '/_authed/tournaments/$id/predictions_/$playerId'
|
||||
| '/_authed/tournaments/$id/predictions_/make'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/_authed/admin/tournaments/$id/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -470,6 +658,9 @@ export interface RootRouteChildren {
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
|
||||
ApiPushTestRoute: typeof ApiPushTestRoute
|
||||
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
|
||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||
@@ -477,6 +668,7 @@ export interface RootRouteChildren {
|
||||
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
||||
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
||||
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
||||
ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
|
||||
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
||||
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
||||
}
|
||||
@@ -574,6 +766,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/telemetry/beacon': {
|
||||
id: '/api/telemetry/beacon'
|
||||
path: '/api/telemetry/beacon'
|
||||
fullPath: '/api/telemetry/beacon'
|
||||
preLoaderRoute: typeof ApiTelemetryBeaconRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/teams/upload-logo': {
|
||||
id: '/api/teams/upload-logo'
|
||||
path: '/api/teams/upload-logo'
|
||||
@@ -623,6 +822,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/unsubscribe': {
|
||||
id: '/api/push/unsubscribe'
|
||||
path: '/api/push/unsubscribe'
|
||||
fullPath: '/api/push/unsubscribe'
|
||||
preLoaderRoute: typeof ApiPushUnsubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/test': {
|
||||
id: '/api/push/test'
|
||||
path: '/api/push/test'
|
||||
fullPath: '/api/push/test'
|
||||
preLoaderRoute: typeof ApiPushTestRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/push/subscribe': {
|
||||
id: '/api/push/subscribe'
|
||||
path: '/api/push/subscribe'
|
||||
fullPath: '/api/push/subscribe'
|
||||
preLoaderRoute: typeof ApiPushSubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/events/$': {
|
||||
id: '/api/events/$'
|
||||
path: '/api/events/$'
|
||||
@@ -658,6 +878,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/telemetry': {
|
||||
id: '/_authed/admin/telemetry'
|
||||
path: '/telemetry'
|
||||
fullPath: '/admin/telemetry'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/preview': {
|
||||
id: '/_authed/admin/preview'
|
||||
path: '/preview'
|
||||
@@ -686,6 +913,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/': {
|
||||
id: '/_authed/admin/telemetry/'
|
||||
path: '/'
|
||||
fullPath: '/admin/telemetry/'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions': {
|
||||
id: '/_authed/tournaments/$id/predictions'
|
||||
path: '/tournaments/$id/predictions'
|
||||
fullPath: '/tournaments/$id/predictions'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/groups': {
|
||||
id: '/_authed/tournaments/$id/groups'
|
||||
path: '/tournaments/$id/groups'
|
||||
@@ -700,6 +941,41 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/usage': {
|
||||
id: '/_authed/admin/telemetry/usage'
|
||||
path: '/usage'
|
||||
fullPath: '/admin/telemetry/usage'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryUsageRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/sessions': {
|
||||
id: '/_authed/admin/telemetry/sessions'
|
||||
path: '/sessions'
|
||||
fullPath: '/admin/telemetry/sessions'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetrySessionsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/players': {
|
||||
id: '/_authed/admin/telemetry/players'
|
||||
path: '/players'
|
||||
fullPath: '/admin/telemetry/players'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryPlayersRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/functions': {
|
||||
id: '/_authed/admin/telemetry/functions'
|
||||
path: '/functions'
|
||||
fullPath: '/admin/telemetry/functions'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryFunctionsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/telemetry/errors': {
|
||||
id: '/_authed/admin/telemetry/errors'
|
||||
path: '/errors'
|
||||
fullPath: '/admin/telemetry/errors'
|
||||
preLoaderRoute: typeof AuthedAdminTelemetryErrorsRouteImport
|
||||
parentRoute: typeof AuthedAdminTelemetryRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/': {
|
||||
id: '/_authed/admin/tournaments/$id/'
|
||||
path: '/tournaments/$id'
|
||||
@@ -714,6 +990,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions_/make': {
|
||||
id: '/_authed/tournaments/$id/predictions_/make'
|
||||
path: '/tournaments/$id/predictions/make'
|
||||
fullPath: '/tournaments/$id/predictions/make'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsMakeRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/predictions_/$playerId': {
|
||||
id: '/_authed/tournaments/$id/predictions_/$playerId'
|
||||
path: '/tournaments/$id/predictions/$playerId'
|
||||
fullPath: '/tournaments/$id/predictions/$playerId'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/run/$id': {
|
||||
id: '/_authed/admin/tournaments/run/$id'
|
||||
path: '/tournaments/run/$id'
|
||||
@@ -728,6 +1018,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/enrollment': {
|
||||
id: '/_authed/admin/tournaments/$id/enrollment'
|
||||
path: '/tournaments/$id/enrollment'
|
||||
fullPath: '/admin/tournaments/$id/enrollment'
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdEnrollmentRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/admin/tournaments/$id/assign-partners': {
|
||||
id: '/_authed/admin/tournaments/$id/assign-partners'
|
||||
path: '/tournaments/$id/assign-partners'
|
||||
@@ -738,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 {
|
||||
AuthedAdminActivitiesRoute: typeof AuthedAdminActivitiesRoute
|
||||
AuthedAdminBadgesRoute: typeof AuthedAdminBadgesRoute
|
||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||
AuthedAdminTelemetryRoute: typeof AuthedAdminTelemetryRouteWithChildren
|
||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
AuthedAdminTournamentsIdEnrollmentRoute: typeof AuthedAdminTournamentsIdEnrollmentRoute
|
||||
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||
@@ -754,10 +1074,13 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||
AuthedAdminActivitiesRoute: AuthedAdminActivitiesRoute,
|
||||
AuthedAdminBadgesRoute: AuthedAdminBadgesRoute,
|
||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||
AuthedAdminTelemetryRoute: AuthedAdminTelemetryRouteWithChildren,
|
||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute:
|
||||
AuthedAdminTournamentsIdAssignPartnersRoute,
|
||||
AuthedAdminTournamentsIdEnrollmentRoute:
|
||||
AuthedAdminTournamentsIdEnrollmentRoute,
|
||||
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||
@@ -779,6 +1102,9 @@ interface AuthedRouteChildren {
|
||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
|
||||
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
|
||||
}
|
||||
|
||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
@@ -793,6 +1119,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute:
|
||||
AuthedTournamentsIdPredictionsPlayerIdRoute,
|
||||
AuthedTournamentsIdPredictionsMakeRoute:
|
||||
AuthedTournamentsIdPredictionsMakeRoute,
|
||||
}
|
||||
|
||||
const AuthedRouteWithChildren =
|
||||
@@ -806,6 +1137,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
|
||||
ApiPushTestRoute: ApiPushTestRoute,
|
||||
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
|
||||
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||
@@ -813,6 +1147,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
||||
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
||||
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
||||
ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
|
||||
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
||||
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
||||
}
|
||||
@@ -821,10 +1156,11 @@ export const routeTree = rootRouteImport
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
import type { startInstance } from './start.ts'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
config: Awaited<ReturnType<typeof startInstance.getOptions>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||
import RoutePending from "../components/route-pending";
|
||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||
|
||||
export function getRouter() {
|
||||
@@ -30,6 +31,9 @@ export function getRouter() {
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 60_000,
|
||||
defaultPendingMs: 100,
|
||||
defaultPendingMinMs: 0,
|
||||
defaultPendingComponent: RoutePending,
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
});
|
||||
|
||||
|
||||
+64
-12
@@ -5,11 +5,13 @@ import {
|
||||
Scripts,
|
||||
createRootRouteWithContext,
|
||||
isRedirect,
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
||||
import { type QueryClient } from "@tanstack/react-query";
|
||||
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
|
||||
import { initTelemetry, track } from "@/lib/telemetry/client";
|
||||
import { AuthContextType } from "@/contexts/auth-context";
|
||||
import Providers from "@/features/core/components/providers";
|
||||
import { SessionMonitor } from "@/components/session-monitor";
|
||||
@@ -18,9 +20,18 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ensureMessages,
|
||||
getCachedMessages,
|
||||
i18nMessagesQueryKey,
|
||||
resolveLocale,
|
||||
} from "@/lib/i18n";
|
||||
import { getRootMeta } from "@/lib/i18n/meta";
|
||||
import FullScreenLoader from "@/components/full-screen-loader";
|
||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||
import mantineCssUrl from '@mantine/core/styles.css?url'
|
||||
import appStylesUrl from '../styles.css?url'
|
||||
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
|
||||
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
|
||||
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
|
||||
@@ -33,7 +44,13 @@ export const Route = createRootRouteWithContext<{
|
||||
withPadding: boolean;
|
||||
fullWidth: boolean;
|
||||
}>()({
|
||||
head: () => ({
|
||||
head: () => {
|
||||
// Meta stays DEFAULT_LOCALE: crawlers/unfurlers have no session, and
|
||||
// reading match.context here creates circular route-type inference that
|
||||
// breaks beforeLoad typing in child routes.
|
||||
const rootMeta = getRootMeta(DEFAULT_LOCALE);
|
||||
|
||||
return {
|
||||
title: "FLXN IX",
|
||||
meta: [
|
||||
{
|
||||
@@ -44,21 +61,21 @@ export const Route = createRootRouteWithContext<{
|
||||
content:
|
||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
||||
},
|
||||
{ name: 'description', content: 'Amicus meus madidus' },
|
||||
{ name: 'description', content: rootMeta.description },
|
||||
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
||||
{ property: 'og:title', content: 'FLXN' },
|
||||
{ property: 'og:description', content: 'Amicus meus madidus' },
|
||||
{ property: 'og:description', content: rootMeta.description },
|
||||
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||
{ property: 'og:type', content: 'website' },
|
||||
{ property: 'og:site_name', content: 'FLXN' },
|
||||
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' },
|
||||
{ property: 'og:image:width', content: '512' },
|
||||
{ property: 'og:image:height', content: '512' },
|
||||
{ property: 'og:image:alt', content: 'FLXN logo' },
|
||||
{ property: 'og:locale', content: 'en_US' },
|
||||
{ property: 'og:image:alt', content: rootMeta.ogImageAlt },
|
||||
{ property: 'og:locale', content: rootMeta.ogLocale },
|
||||
{ name: 'twitter:card', content: 'summary' },
|
||||
{ name: 'twitter:title', content: 'FLXN' },
|
||||
{ name: 'twitter:description', content: 'Amicus meus madidus' },
|
||||
{ name: 'twitter:description', content: rootMeta.description },
|
||||
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||
@@ -85,6 +102,7 @@ export const Route = createRootRouteWithContext<{
|
||||
},
|
||||
{ rel: "manifest", href: "/site.webmanifest" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
{ rel: 'stylesheet', href: appStylesUrl },
|
||||
{ rel: 'stylesheet', href: mantineCssUrl },
|
||||
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
|
||||
{ rel: 'stylesheet', href: mantineDatesCssUrl },
|
||||
@@ -100,7 +118,8 @@ export const Route = createRootRouteWithContext<{
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=League+Spartan:wght@100..900&display=swap",
|
||||
}
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
errorComponent: (props) => {
|
||||
return (
|
||||
<RootDocument>
|
||||
@@ -123,6 +142,14 @@ export const Route = createRootRouteWithContext<{
|
||||
context.queryClient,
|
||||
playerQueries.auth()
|
||||
);
|
||||
const locale = resolveLocale(auth?.metadata?.locale);
|
||||
await ensureMessages(locale);
|
||||
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
|
||||
context.queryClient.setQueryData(
|
||||
i18nMessagesQueryKey(locale),
|
||||
getCachedMessages(locale)
|
||||
);
|
||||
}
|
||||
return { auth };
|
||||
} catch (error: any) {
|
||||
if (isRedirect(error) || error instanceof Response) throw error;
|
||||
@@ -138,6 +165,7 @@ export const Route = createRootRouteWithContext<{
|
||||
context.queryClient,
|
||||
playerQueries.auth()
|
||||
);
|
||||
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
||||
return { auth };
|
||||
} catch {
|
||||
return {};
|
||||
@@ -151,12 +179,27 @@ export const Route = createRootRouteWithContext<{
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
ensureSuperTokensFrontend();
|
||||
initTelemetry(router);
|
||||
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 (
|
||||
<RootDocument>
|
||||
@@ -169,7 +212,6 @@ function RootComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
// todo: analytics -> process.env data-website-id
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html
|
||||
@@ -192,8 +234,17 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
media="(prefers-color-scheme: dark)"
|
||||
content={CHROME_COLORS.dark.base}
|
||||
/>
|
||||
<ColorSchemeScript />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<ColorSchemeScript defaultColorScheme="auto" />
|
||||
{/* Pre-hydration theme restore from ThemeProvider's localStorage
|
||||
mirrors: data-theme (so exotic themes don't flash default),
|
||||
forced color scheme (dark-only themes must not paint light),
|
||||
and the theme's font stylesheets (avoid FOUT until hydration). */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
'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){}',
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
style={{
|
||||
@@ -205,6 +256,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<div className="app">{children}</div>
|
||||
<div className="flxn-theme-overlay" aria-hidden="true" />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import type { HeaderConfig } from "@/features/core/types/header-config";
|
||||
import { isAdminRole } from "@/features/core/utils/roles";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin")({
|
||||
component: Outlet,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.auth?.roles?.includes("Admin")) {
|
||||
if (!isAdminRole(context.auth?.roles)) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
|
||||
return {
|
||||
header: {
|
||||
const header: HeaderConfig = {
|
||||
...context.header,
|
||||
title: "Admin",
|
||||
title: msg`Admin`,
|
||||
withBackButton: true,
|
||||
},
|
||||
};
|
||||
|
||||
return { header };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,73 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { ActivitiesTable, activityQueries } from "@/features/activities";
|
||||
import { PlayersActivityTable, playerQueries } from "@/features/players";
|
||||
import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
|
||||
import { Suspense, useState } from "react";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||
component: Stats,
|
||||
beforeLoad: ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
prefetchServerQuery(queryClient, activityQueries.search());
|
||||
prefetchServerQuery(queryClient, playerQueries.activity());
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/admin/telemetry/functions" });
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
header: {
|
||||
title: "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">Server Functions</Tabs.Tab>
|
||||
<Tabs.Tab value="player-activity">Player Activity</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AdminPage } from "@/features/admin";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { AdminDashboard } from "@/features/admin";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/")({
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, tournamentQueries.current());
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: "Admin",
|
||||
title: msg`Admin`,
|
||||
},
|
||||
withPadding: false,
|
||||
}),
|
||||
component: () => <AdminPage />,
|
||||
component: () => <AdminDashboard />,
|
||||
});
|
||||
|
||||
@@ -2,13 +2,15 @@ import BracketPreview from "@/features/admin/components/preview";
|
||||
import { NumberInput } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/preview")({
|
||||
component: RouteComponent,
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: "Bracket Preview",
|
||||
title: msg`Bracket Preview`,
|
||||
},
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
@@ -16,13 +18,14 @@ export const Route = createFileRoute("/_authed/admin/preview")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { t } = useLingui();
|
||||
const [n, setN] = useState(16);
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
min={9}
|
||||
max={27}
|
||||
label="Number of teams"
|
||||
label={t`Number of teams`}
|
||||
value={n}
|
||||
onChange={(value) => setN(value as number)}
|
||||
w={150}
|
||||
|
||||
@@ -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 { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { StatTileSkeleton } from "@/components/skeletons";
|
||||
import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
|
||||
@@ -9,6 +10,8 @@ import TeamAssignmentPreview from "@/features/tournaments/components/team-assign
|
||||
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trans, Plural } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -23,7 +26,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
|
||||
loader: ({ context }) => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `Manage ${context.tournament.name}`,
|
||||
title: msg`Manage {name}`,
|
||||
titleValues: { name: context.tournament.name },
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
@@ -34,10 +38,7 @@ function AssignPartnersPending() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Skeleton height={28} width={36} radius="sm" />
|
||||
<Skeleton height={14} width={110} radius="sm" />
|
||||
</Group>
|
||||
<StatTileSkeleton align="row" />
|
||||
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -120,19 +121,19 @@ function RouteComponent() {
|
||||
{freeAgents.length}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"}
|
||||
<Plural value={freeAgents.length} one="player enrolled" other="players enrolled" />
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{!hasEnoughPlayers && (
|
||||
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
|
||||
Need at least 2 players to create teams
|
||||
<Trans>Need at least 2 players to create teams</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasOddPlayers && (
|
||||
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
|
||||
Cannot create teams with an odd number of players. Please have one player unenroll.
|
||||
<Trans>Cannot create teams with an odd number of players. Please have one player unenroll.</Trans>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -142,7 +143,7 @@ function RouteComponent() {
|
||||
onClick={handleGenerate}
|
||||
loading={generateMutation.isPending}
|
||||
>
|
||||
Generate Random Pairings
|
||||
<Trans>Generate Random Pairings</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -151,7 +152,7 @@ function RouteComponent() {
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
Partner Assignments
|
||||
<Trans>Partner Assignments</Trans>
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
@@ -161,7 +162,7 @@ function RouteComponent() {
|
||||
loading={generateMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Re-roll
|
||||
<Trans>Re-roll</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CheckCircleIcon size={18} />}
|
||||
@@ -169,7 +170,7 @@ function RouteComponent() {
|
||||
loading={confirmMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Confirm & Create Teams
|
||||
<Trans>Confirm & Create Teams</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { ListSkeleton } from "@/components/skeletons";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -19,7 +20,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||
loader: ({ context }) => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `Manage ${context.tournament.name}`,
|
||||
title: msg`Manage {name}`,
|
||||
titleValues: { name: context.tournament.name },
|
||||
},
|
||||
withPadding: false,
|
||||
}),
|
||||
@@ -29,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||
|
||||
function ManageTournamentPending() {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={20} width={20} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
<ListSkeleton
|
||||
rows={5}
|
||||
withAvatar
|
||||
avatarSize={20}
|
||||
withTrailing
|
||||
fadeStep={0.14}
|
||||
fadeFloor={0.4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import ManageTeams from "@/features/teams/components/manage-teams";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { 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";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -19,7 +21,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||
loader: ({ context }) => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.tournament.name} Teams`,
|
||||
title: msg`{name} Teams`,
|
||||
titleValues: { name: context.tournament.name },
|
||||
},
|
||||
withPadding: false,
|
||||
}),
|
||||
@@ -36,21 +39,7 @@ function ManageTeamsPending() {
|
||||
<Box px="md">
|
||||
<Skeleton height={12} width={90} radius="sm" />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
||||
<Group p="xs" wrap="nowrap" w="100%">
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Stack ml="auto" gap={6} align="flex-end">
|
||||
<Skeleton height={10} width={90} radius="sm" />
|
||||
<Skeleton height={10} width={70} radius="sm" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
<ListSkeleton rows={8} withAvatar avatarSize={40} withTrailing="double" padding="xs" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import ManageTournaments from "@/features/admin/components/manage-tournaments";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
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 { Suspense } from "react";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||
beforeLoad: ({ context }) => {
|
||||
@@ -13,7 +14,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: "Manage Tournaments",
|
||||
title: msg`Manage Tournaments`,
|
||||
},
|
||||
refresh: tournamentQueries.list().queryKey,
|
||||
withPadding: false,
|
||||
@@ -21,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function TournamentListSkeleton() {
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={16} width="55%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<Suspense fallback={<TournamentListSkeleton />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<ListSkeleton rows={6} titleWidth="55%" withTrailing fadeStep={0.12} fadeFloor={0.4} />
|
||||
}
|
||||
>
|
||||
<ManageTournaments />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -8,12 +8,12 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
||||
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
import { SpotifyControlsBar } from "@/features/spotify/components";
|
||||
import { 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 { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -33,7 +33,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
||||
showSpotifyPanel: true,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `Run ${context.tournament.name}`,
|
||||
title: msg`Run {name}`,
|
||||
titleValues: { name: context.tournament.name },
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
@@ -80,57 +81,10 @@ function RunTournamentPending() {
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const hasKnockout = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round !== -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const knockoutBracketPopulated = useMemo(() => {
|
||||
return tournament.matches?.some((match) =>
|
||||
match.round === 0 && match.lid >= 0 && (match.home || match.away)
|
||||
) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const 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]);
|
||||
const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } =
|
||||
useTournamentRunState(tournament.matches);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
@@ -146,11 +100,12 @@ function RouteComponent() {
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
isRegional={tournament.regional}
|
||||
groupConfig={tournament.group_config}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
<Title order={3} ta="center" mb="md"><Trans>Knockout Bracket</Trans></Title>
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||
</div>
|
||||
</Stack>
|
||||
) : hasGroupStage ? (
|
||||
@@ -162,9 +117,10 @@ function RouteComponent() {
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
isRegional={tournament.regional}
|
||||
groupConfig={tournament.group_config}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
) : (
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
||||
)
|
||||
) : (
|
||||
tournament.regional === true ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ import PlayerStatsTableSkeleton from '@/features/players/components/player-stats
|
||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Suspense } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
export const Route = createFileRoute('/_authed/badges')({
|
||||
component: Badges,
|
||||
@@ -16,7 +17,7 @@ export const Route = createFileRoute('/_authed/badges')({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
header: {
|
||||
title: 'All Badges',
|
||||
title: msg`All Badges`,
|
||||
withBackButton: true,
|
||||
},
|
||||
refresh: [badgeQueries.allBadges().queryKey],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
|
||||
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 { Suspense } from "react";
|
||||
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
|
||||
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
|
||||
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
|
||||
<Home />
|
||||
</Suspense>,
|
||||
beforeLoad: async ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
|
||||
|
||||
return { tournament }
|
||||
beforeLoad: ({ context }) => {
|
||||
prefetchServerQuery(context.queryClient, tournamentQueries.current());
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
withPadding: false,
|
||||
header: {
|
||||
title: context.tournament.name || "FLXN"
|
||||
title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
|
||||
}
|
||||
}),
|
||||
pendingComponent: () => <UpcomingTournamentSkeleton />
|
||||
|
||||
@@ -14,26 +14,12 @@ const searchSchema = z.object({
|
||||
|
||||
export const Route = createFileRoute("/_authed/profile/$playerId")({
|
||||
validateSearch: searchSchema,
|
||||
beforeLoad: async ({ params, context }) => {
|
||||
beforeLoad: ({ params, context }) => {
|
||||
const { queryClient } = context;
|
||||
await Promise.all([
|
||||
prefetchServerQuery(
|
||||
queryClient,
|
||||
playerQueries.details(params.playerId)
|
||||
),
|
||||
prefetchServerQuery(
|
||||
queryClient,
|
||||
playerQueries.matches(params.playerId)
|
||||
),
|
||||
prefetchServerQuery(
|
||||
queryClient,
|
||||
playerQueries.stats(params.playerId)
|
||||
),
|
||||
prefetchServerQuery(
|
||||
queryClient,
|
||||
badgeQueries.playerBadges(params.playerId)
|
||||
),
|
||||
]);
|
||||
prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
|
||||
prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
|
||||
prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
|
||||
prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
|
||||
},
|
||||
loader: ({ params, context }) => ({
|
||||
header: {
|
||||
|
||||
@@ -2,13 +2,18 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Box, Title, Stack } from "@mantine/core";
|
||||
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
||||
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
||||
import ThemePicker from "@/features/settings/components/theme-picker";
|
||||
import LocalePicker from "@/features/settings/components/locale-picker";
|
||||
import { NotificationsSection } from "@/features/settings/components/notifications-section";
|
||||
import { SignOutIcon } from "@phosphor-icons/react";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/settings")({
|
||||
loader: () => ({
|
||||
header: {
|
||||
title: "Settings",
|
||||
title: msg`Settings`,
|
||||
withBackButton: true,
|
||||
},
|
||||
withPadding: false,
|
||||
@@ -17,6 +22,7 @@ export const Route = createFileRoute("/_authed/settings")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
@@ -26,13 +32,16 @@ function RouteComponent() {
|
||||
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<Title order={3}>Appearance</Title>
|
||||
<Title order={3}><Trans>Appearance</Trans></Title>
|
||||
<Stack>
|
||||
<ThemePicker />
|
||||
<AccentColorPicker />
|
||||
<ColorSchemePicker />
|
||||
<LocalePicker />
|
||||
</Stack>
|
||||
</Box>
|
||||
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
||||
<NotificationsSection />
|
||||
<ListLink label={t`Sign Out`} to="/logout" Icon={SignOutIcon} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||
import { Suspense, useState, useDeferredValue } from "react";
|
||||
import { Suspense, useState, useDeferredValue, useEffect, useRef } from "react";
|
||||
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
|
||||
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { track } from "@/lib/telemetry/client";
|
||||
|
||||
export const Route = createFileRoute("/_authed/stats")({
|
||||
component: Stats,
|
||||
beforeLoad: ({ context }) => {
|
||||
const queryClient = context.queryClient;
|
||||
prefetchServerQuery(queryClient, playerQueries.allStats('all'));
|
||||
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
||||
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
||||
},
|
||||
loader: () => ({
|
||||
withPadding: false,
|
||||
fullWidth: true,
|
||||
header: {
|
||||
title: "Player Stats"
|
||||
title: msg`Player Stats`
|
||||
},
|
||||
refresh: [playerQueries.allStats().queryKey],
|
||||
}),
|
||||
@@ -29,12 +31,29 @@ function Stats() {
|
||||
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
|
||||
const deferredViewType = useDeferredValue(viewType);
|
||||
const isStale = viewType !== deferredViewType;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const viewTypeRef = useRef(viewType);
|
||||
const changeViewType = (next: typeof viewType) => {
|
||||
if (viewTypeRef.current === next) return;
|
||||
viewTypeRef.current = next;
|
||||
track("stats_filtered", { value: next });
|
||||
setViewType(next);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
|
||||
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
|
||||
}, 1500);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [queryClient]);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="stats">
|
||||
<Tabs.List grow>
|
||||
<Tabs.Tab value="stats">Stats</Tabs.Tab>
|
||||
<Tabs.Tab value="h2h">Head to Head</Tabs.Tab>
|
||||
<Tabs.Tab value="stats"><Trans>Stats</Trans></Tabs.Tab>
|
||||
<Tabs.Tab value="h2h"><Trans>Head to Head</Trans></Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="stats">
|
||||
@@ -44,23 +63,23 @@ function Stats() {
|
||||
<Button
|
||||
variant={viewType === 'all' ? 'filled' : 'light'}
|
||||
size="compact-xs"
|
||||
onClick={() => setViewType('all')}
|
||||
onClick={() => changeViewType('all')}
|
||||
>
|
||||
All
|
||||
<Trans>All</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewType === 'mainline' ? 'filled' : 'light'}
|
||||
size="compact-xs"
|
||||
onClick={() => setViewType('mainline')}
|
||||
onClick={() => changeViewType('mainline')}
|
||||
>
|
||||
Mainline
|
||||
<Trans>Mainline</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewType === 'regional' ? 'filled' : 'light'}
|
||||
size="compact-xs"
|
||||
onClick={() => setViewType('regional')}
|
||||
onClick={() => changeViewType('regional')}
|
||||
>
|
||||
Regional
|
||||
<Trans>Regional</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||
|
||||
@@ -12,9 +12,9 @@ const searchSchema = z.object({
|
||||
|
||||
export const Route = createFileRoute("/_authed/teams/$teamId")({
|
||||
validateSearch: searchSchema,
|
||||
beforeLoad: async ({ params, context }) => {
|
||||
beforeLoad: ({ params, context }) => {
|
||||
const { queryClient } = context;
|
||||
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
|
||||
prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
|
||||
},
|
||||
loader: ({ params }) => ({
|
||||
header: {
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
useTournament,
|
||||
} from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
|
||||
import { Container } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
|
||||
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -34,84 +35,14 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function BracketPending() {
|
||||
const columns = [4, 2, 1];
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<Box
|
||||
p={0}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
minHeight: "70dvh",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||
<Flex gap="xl" px={16} align="stretch">
|
||||
{columns.map((count, columnIndex) => (
|
||||
<Stack
|
||||
key={`bracket-pending-round-${columnIndex}`}
|
||||
gap="xl"
|
||||
justify="space-around"
|
||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||
<Skeleton
|
||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||
height={84}
|
||||
width={220}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
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]);
|
||||
const bracket: BracketData = useMemo(
|
||||
() => groupMatchesIntoBracket(tournament.matches),
|
||||
[tournament.matches]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Container } from "@mantine/core";
|
||||
import { PredictionLeaderboard } from "@/features/predictions/components/prediction-leaderboard";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
|
||||
export const Route = createFileRoute("/_authed/tournaments/$id/predictions")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: msg`Predictions`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<PredictionLeaderboard tournament={tournament} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { useMemo } from "react";
|
||||
import { Box, Container, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { predictionQueries, usePlayerPrediction } from "@/features/predictions/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
import { PredictionBracket } from "@/features/predictions/components/prediction-bracket";
|
||||
import { computePredictionScore } from "@/features/predictions/utils";
|
||||
import PlayerAvatar from "@/components/player-avatar";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authed/tournaments/$id/predictions_/$playerId"
|
||||
)({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
|
||||
const prediction = await ensureServerQueryData(
|
||||
queryClient,
|
||||
predictionQueries.player(params.id, params.playerId)
|
||||
);
|
||||
if (!prediction) {
|
||||
throw redirect({
|
||||
to: "/tournaments/$id/predictions",
|
||||
params: { id: params.id },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tournament,
|
||||
prediction,
|
||||
};
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: msg`{name}'s Bracket`,
|
||||
titleValues: { name: context.prediction.player.first_name },
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id, playerId } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
const { data: prediction } = usePlayerPrediction(id, playerId);
|
||||
|
||||
const matches = tournament.matches || [];
|
||||
const picks = prediction?.picks ?? {};
|
||||
|
||||
const score = useMemo(
|
||||
() => computePredictionScore(matches, picks),
|
||||
[matches, picks]
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<Box pos="relative">
|
||||
<PredictionBracket
|
||||
matches={matches}
|
||||
picks={picks}
|
||||
mode="view"
|
||||
perMatch={score.perMatch}
|
||||
/>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
p="md"
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="lg"
|
||||
p="sm"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<PlayerAvatar
|
||||
name={`${prediction?.player.first_name} ${prediction?.player.last_name}`}
|
||||
size={32}
|
||||
disableFullscreen
|
||||
/>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{prediction?.player.first_name} {prediction?.player.last_name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
<Trans>PTS</Trans>
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{score.points}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} ta="center">
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
<Trans>PICKS</Trans>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{score.correct}/{score.total}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { tournamentQueries, useTournament } from "@/features/tournaments/queries";
|
||||
import { predictionQueries, useMyPrediction } from "@/features/predictions/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { Container } from "@mantine/core";
|
||||
import { BracketPending } from "@/features/bracket/components/bracket-pending";
|
||||
import { PredictionEditor } from "@/features/predictions/components/prediction-editor";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authed/tournaments/$id/predictions_/make"
|
||||
)({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
|
||||
const myPrediction = await ensureServerQueryData(
|
||||
queryClient,
|
||||
predictionQueries.mine(params.id)
|
||||
);
|
||||
if (!myPrediction.eligible || myPrediction.locked) {
|
||||
throw redirect({
|
||||
to: "/tournaments/$id/predictions",
|
||||
params: { id: params.id },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.tournament.name}`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
pendingComponent: BracketPending,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
const { data: myPrediction } = useMyPrediction(id);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<PredictionEditor
|
||||
tournament={tournament}
|
||||
initialPicks={myPrediction.prediction?.picks ?? {}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { tournamentQueries } from '@/features/tournaments/queries';
|
||||
import Profile from '@/features/tournaments/components/profile';
|
||||
import { isAdminRole } from '@/features/core/utils/roles';
|
||||
import { z } from "zod";
|
||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||
import { Suspense } from 'react';
|
||||
@@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
header: {
|
||||
collapsed: 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],
|
||||
withPadding: false
|
||||
|
||||
@@ -4,6 +4,7 @@ import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
||||
import { Suspense } from 'react'
|
||||
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
|
||||
import { Skeleton, Stack } from '@mantine/core'
|
||||
import { msg } from '@lingui/core/macro'
|
||||
|
||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -13,7 +14,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
loader: () => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: 'Tournaments',
|
||||
title: msg`Tournaments`,
|
||||
},
|
||||
refresh: tournamentQueries.list().queryKey
|
||||
}),
|
||||
|
||||
@@ -8,8 +8,6 @@ ensureSuperTokensBackend();
|
||||
// forwards all supertokens api methods to our API
|
||||
const superTokensHandler = handleAuthAPIRequest();
|
||||
const handleRequest = async ({ request }: {request: Request}) => {
|
||||
console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
|
||||
console.log("Handling auth request:", request.method, request.url);
|
||||
return superTokensHandler(request);
|
||||
};
|
||||
export const Route = createFileRoute('/api/auth/$')({
|
||||
|
||||
@@ -1,75 +1,66 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
||||
import {
|
||||
serverEvents,
|
||||
EVENT_TYPES,
|
||||
incrementConnections,
|
||||
decrementConnections,
|
||||
type ServerEvent,
|
||||
} from "@/lib/events/emitter";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
|
||||
let activeConnections = 0;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export const Route = createFileRoute("/api/events/$")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
GET: ({ request }) => {
|
||||
activeConnections++;
|
||||
const active = incrementConnections();
|
||||
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 cleanup = () => {};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||
|
||||
const handleEvent = (event: ServerEvent) => {
|
||||
logger.info("ServerEvents | Event received", event);
|
||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||
const send = (payload: unknown) => {
|
||||
try {
|
||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||
logger.warn("ServerEvents | Stream closed, skipping event");
|
||||
return;
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode(message));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
||||
} catch (error) {
|
||||
logger.error("ServerEvents | Error sending SSE message", error);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
serverEvents.on("test", handleEvent);
|
||||
serverEvents.on("match", handleEvent);
|
||||
serverEvents.on("reaction", handleEvent);
|
||||
const handleEvent = (event: ServerEvent) => send(event);
|
||||
for (const type of EVENT_TYPES) {
|
||||
serverEvents.on(type, handleEvent);
|
||||
}
|
||||
|
||||
// Under the shortest idle timeout observed on proxies/PWA paths.
|
||||
const pingInterval = setInterval(() => {
|
||||
try {
|
||||
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||
} catch (e) {
|
||||
logger.error("ServerEvents | Ping interval error", e);
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
}, 15000);
|
||||
send({ type: "ping", timestamp: Date.now() });
|
||||
}, 8000);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
||||
} catch (e) {
|
||||
logger.error("ServerEvents | Heartbeat error", e);
|
||||
cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
const remaining = decrementConnections();
|
||||
for (const type of EVENT_TYPES) {
|
||||
serverEvents.off(type, handleEvent);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const cleanup = () => {
|
||||
activeConnections--;
|
||||
serverEvents.off("test", handleEvent);
|
||||
serverEvents.off("match", handleEvent);
|
||||
serverEvents.off("reaction", handleEvent);
|
||||
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);
|
||||
return cleanup;
|
||||
|
||||
send({ type: "connected" });
|
||||
},
|
||||
cancel() {
|
||||
cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,13 +68,7 @@ export const Route = createFileRoute("/api/events/$")({
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Cache-Control",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Proxy-Buffering": "no",
|
||||
"Proxy-Buffering": "off",
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ensureTelemetryScheduler } from "@/lib/telemetry/scheduler.server";
|
||||
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
|
||||
import { ensureSuperTokensBackend } from "@/lib/supertokens/server";
|
||||
|
||||
export const Route = createFileRoute("/api/health")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => {
|
||||
GET: async () => {
|
||||
ensureSuperTokensBackend();
|
||||
ensureTelemetryScheduler();
|
||||
const snapshot = await getHealthSnapshot();
|
||||
|
||||
// Always 200: a dependency outage must not fail the k8s probes.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
status: snapshot.status,
|
||||
checks: snapshot.checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/subscribe")({
|
||||
server: {
|
||||
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const sub = payload?.subscription ?? payload;
|
||||
const endpoint: string | undefined = sub?.endpoint;
|
||||
const p256dh: string | undefined = sub?.keys?.p256dh;
|
||||
const auth: string | undefined = sub?.keys?.auth;
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
return json({ error: "Invalid subscription" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await pbAdmin.upsertPushSubscription({
|
||||
player: player.id,
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
user_agent: request.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error("Push | subscribe failed", error);
|
||||
return json({ error: "Failed to store subscription" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||
import { sendPushToPlayer } from "@/lib/push";
|
||||
import { isPushConfigured } from "@/lib/config";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/test")({
|
||||
server: {
|
||||
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
if (!isPushConfigured()) {
|
||||
return json({ error: "Push is not configured on the server" }, 503);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendPushToPlayer(player.id, {
|
||||
title: "Flexxon",
|
||||
body: msg`Test notification — push is working on this device.`,
|
||||
url: "/settings",
|
||||
tag: "flexxon-test",
|
||||
});
|
||||
return json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
logger.error("Push | test send failed", error);
|
||||
return json({ error: "Failed to send test notification" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/push/unsubscribe")({
|
||||
server: {
|
||||
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const player = (context as any).player;
|
||||
if (!player) return json({ error: "No player for session" }, 401);
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const endpoint: string | undefined =
|
||||
payload?.endpoint ?? payload?.subscription?.endpoint;
|
||||
if (!endpoint) return json({ error: "Missing endpoint" }, 400);
|
||||
|
||||
try {
|
||||
await pbAdmin.deletePushSubscriptionByEndpoint(endpoint);
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error("Push | unsubscribe failed", error);
|
||||
return json({ error: "Failed to remove subscription" }, 500);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||
|
||||
export const Route = createFileRoute("/api/spotify/capture")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||
const i18n = localizedFor(null);
|
||||
try {
|
||||
const cookies = request.headers.get("Cookie") || "";
|
||||
const accessTokenMatch = cookies.match(
|
||||
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -29,7 +33,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No active playback to capture" }),
|
||||
JSON.stringify({ error: i18n._(msg`No active playback to capture`) }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -47,7 +51,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to capture playback state";
|
||||
: i18n._(msg`Failed to capture playback state`);
|
||||
|
||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||
status: 500,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||
|
||||
function getAccessTokenFromCookies(request: Request): string | null {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
@@ -16,11 +18,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||
const i18n = localizedFor(null);
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request);
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -41,7 +45,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
if (!trackId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "trackId is required for playTrack action",
|
||||
error: i18n._(msg`trackId is required for playTrack action`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -63,7 +67,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
case "volume":
|
||||
if (typeof volumePercent !== "number") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "volumePercent must be a number" }),
|
||||
JSON.stringify({ error: i18n._(msg`volumePercent must be a number`) }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -76,7 +80,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
if (!deviceId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "deviceId is required for transfer action",
|
||||
error: i18n._(msg`deviceId is required for transfer action`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -87,10 +91,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
await spotifyClient.transferPlayback(deviceId);
|
||||
break;
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
||||
return new Response(
|
||||
JSON.stringify({ error: i18n._(msg`Invalid action`) }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
@@ -104,8 +111,9 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"No active device found. Please select a device first.",
|
||||
error: i18n._(
|
||||
msg`No active device found. Please select a device first.`
|
||||
),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -117,7 +125,9 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
if (error.message.includes("PREMIUM_REQUIRED")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Spotify Premium is required for playback control.",
|
||||
error: i18n._(
|
||||
msg`Spotify Premium is required for playback control.`
|
||||
),
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
@@ -135,7 +145,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Playback control failed",
|
||||
error: i18n._(msg`Playback control failed`),
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
{
|
||||
@@ -147,11 +157,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||
const i18n = localizedFor(null);
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request);
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -190,7 +202,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
|
||||
} catch (error) {
|
||||
console.error("Get playback data error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to get playback data" }),
|
||||
JSON.stringify({ error: i18n._(msg`Failed to get playback data`) }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||
|
||||
export const Route = createFileRoute("/api/spotify/resume")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
// No session middleware on this route; localize with DEFAULT_LOCALE.
|
||||
const i18n = localizedFor(null);
|
||||
try {
|
||||
const cookies = request.headers.get("Cookie") || "";
|
||||
const accessTokenMatch = cookies.match(
|
||||
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
JSON.stringify({ error: i18n._(msg`No access token found`) }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -30,7 +34,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No snapshot provided" }),
|
||||
JSON.stringify({ error: i18n._(msg`No snapshot provided`) }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -47,14 +51,14 @@ export const Route = createFileRoute("/api/spotify/resume")({
|
||||
} catch (error) {
|
||||
console.error("Spotify resume error:", error);
|
||||
|
||||
let errorMessage = "Failed to resume playback state";
|
||||
let errorMessage = i18n._(msg`Failed to resume playback state`);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.includes("Premium") ||
|
||||
error.message.includes("403")
|
||||
) {
|
||||
errorMessage = "Spotify premium required";
|
||||
errorMessage = i18n._(msg`Spotify premium required`);
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { isAdminRole } from "@/features/core/utils/roles";
|
||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||
import { z } from "zod";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
@@ -10,12 +14,13 @@ const uploadSchema = z.object({
|
||||
|
||||
export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const i18n = localizedFor(context.metadata);
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
const isAdmin = isAdminRole(context.roles);
|
||||
|
||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||
|
||||
@@ -27,7 +32,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
if (!validationResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid input",
|
||||
error: i18n._(msg`Invalid input`),
|
||||
details: validationResult.error.issues,
|
||||
}),
|
||||
{
|
||||
@@ -40,7 +45,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Logo file is required",
|
||||
error: i18n._(msg`Logo file is required`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -58,7 +63,9 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||
error: i18n._(
|
||||
msg`Invalid file type. Only JPEG, PNG and GIF are allowed.`
|
||||
),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -71,7 +78,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "File too large. Maximum size is 10MB.",
|
||||
error: i18n._(msg`File too large. Maximum size is 10MB.`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -84,7 +91,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
if (!team) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Team not found",
|
||||
error: i18n._(msg`Team not found`),
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
@@ -132,8 +139,8 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to upload logo",
|
||||
message: error.message || "Unknown error occurred",
|
||||
error: i18n._(msg`Failed to upload logo`),
|
||||
message: error.message || i18n._(msg`Unknown error occurred`),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -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,7 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { msg } from "@lingui/core/macro";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { isAdminRole } from "@/features/core/utils/roles";
|
||||
import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { localizedFor } from "@/lib/i18n/server-messages";
|
||||
import { z } from "zod";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
@@ -10,12 +14,13 @@ const uploadSchema = z.object({
|
||||
|
||||
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
const i18n = localizedFor(context.metadata);
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
const isAdmin = isAdminRole(context.roles);
|
||||
|
||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
||||
@@ -28,7 +33,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
if (!validationResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid input",
|
||||
error: i18n._(msg`Invalid input`),
|
||||
details: validationResult.error.issues,
|
||||
}),
|
||||
{
|
||||
@@ -41,7 +46,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Logo file is required",
|
||||
error: i18n._(msg`Logo file is required`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -59,7 +64,9 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||
error: i18n._(
|
||||
msg`Invalid file type. Only JPEG, PNG and GIF are allowed.`
|
||||
),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -72,7 +79,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "File too large. Maximum size is 10MB.",
|
||||
error: i18n._(msg`File too large. Maximum size is 10MB.`),
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
@@ -85,7 +92,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
if (!tournament) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Tournament not found",
|
||||
error: i18n._(msg`Tournament not found`),
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
@@ -130,8 +137,8 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to upload logo",
|
||||
message: error.message || "Unknown error occurred",
|
||||
error: i18n._(msg`Failed to upload logo`),
|
||||
message: error.message || i18n._(msg`Unknown error occurred`),
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -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],
|
||||
}));
|
||||
@@ -2,6 +2,26 @@
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Keep app chrome non-selectable so iOS long-press doesn't open the
|
||||
edit menu (AutoFill/Copy/Siri). Inputs and opted-in regions stay
|
||||
selectable. */
|
||||
body {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable="true"],
|
||||
[contenteditable=""],
|
||||
[data-selectable],
|
||||
[data-selectable] * {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -30,6 +50,12 @@
|
||||
transform: scale(0.90) translateY(-4px);
|
||||
}
|
||||
|
||||
/* themes paint their full-page texture through this element */
|
||||
.flxn-theme-overlay {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app,
|
||||
[data-drawer-level] {
|
||||
@@ -22,26 +22,30 @@ import { useDisclosure } from '@mantine/hooks'
|
||||
import { useEffect } from 'react'
|
||||
import toast from '@/lib/sonner'
|
||||
import { logger } from '@/lib/logger'
|
||||
import { captureBoundaryError } from '@/lib/telemetry/client'
|
||||
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
import Button from './button'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useLingui()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
select: (state) => state.id === rootRouteId,
|
||||
})
|
||||
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
|
||||
|
||||
const errorMessage = error?.message || 'Unknown error'
|
||||
const errorMessage = error?.message || t`Unknown error`
|
||||
const errorStack = error?.stack || 'No stack trace available'
|
||||
|
||||
useEffect(() => {
|
||||
logger.error('DefaultCatchBoundary | ', error)
|
||||
captureBoundaryError(error)
|
||||
|
||||
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
||||
toast.error('You\'ve been logged out')
|
||||
toast.error(t`You've been logged out`)
|
||||
router.history.push('/login')
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
@@ -53,23 +57,23 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xl" fw={600}>Access Denied</Text>
|
||||
<Text size="xl" fw={600}><Trans>Access Denied</Trans></Text>
|
||||
<Text c="dimmed" ta="center">
|
||||
You don't have permission to access this page.
|
||||
<Trans>You don't have permission to access this page.</Trans>
|
||||
</Text>
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
<Trans>Home</Trans>
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -84,21 +88,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<Stack align="center" gap="md" w="100%">
|
||||
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
|
||||
<Text size="xl" fw={600}>Something went wrong</Text>
|
||||
<Text size="xl" fw={600}><Trans>Something went wrong</Trans></Text>
|
||||
|
||||
<Text c="dimmed" ta="center">
|
||||
An error occurred while loading this page.
|
||||
<Trans>An error occurred while loading this page.</Trans>
|
||||
</Text>
|
||||
|
||||
<Box w="100%" mt="md">
|
||||
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||
<Text size="sm" c="dimmed" mb="xs"><Trans>Error: {errorMessage}</Trans></Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={toggleDetails}
|
||||
fullWidth
|
||||
>
|
||||
{detailsOpened ? 'Hide' : 'Show'} details
|
||||
{detailsOpened ? <Trans>Hide details</Trans> : <Trans>Show details</Trans>}
|
||||
</Button>
|
||||
<Collapse in={detailsOpened}>
|
||||
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||
@@ -112,7 +116,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
variant="light"
|
||||
onClick={() => router.invalidate()}
|
||||
>
|
||||
Retry
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<MantineButton
|
||||
@@ -120,14 +124,14 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
<Trans>Home</Trans>
|
||||
</MantineButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
Avatar as MantineAvatar,
|
||||
AvatarProps as MantineAvatarProps,
|
||||
Paper,
|
||||
Box,
|
||||
Modal,
|
||||
Image,
|
||||
Group,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface AvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
@@ -21,17 +22,26 @@ interface AvatarProps
|
||||
contain?: boolean;
|
||||
}
|
||||
|
||||
// Mantine radius tokens ("sm", "xl") are valid as props but not as raw CSS
|
||||
// values — resolve them before using in style/borderRadius.
|
||||
const resolveRadius = (radius: string | number) =>
|
||||
typeof radius === "string" && /^[a-z]+$/.test(radius)
|
||||
? `var(--mantine-radius-${radius})`
|
||||
: radius;
|
||||
|
||||
const Avatar = ({
|
||||
name,
|
||||
size = 35,
|
||||
radius = "100%",
|
||||
radius = "var(--flxn-avatar-radius, 50%)",
|
||||
withBorder = true,
|
||||
disableFullscreen = false,
|
||||
contain = false,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const { t } = useLingui();
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const hasImage = Boolean(props.src);
|
||||
const frameRadius = resolveRadius(radius);
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (hasImage && !disableFullscreen) {
|
||||
@@ -41,11 +51,20 @@ const Avatar = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
p={size / 20}
|
||||
radius={radius}
|
||||
withBorder={withBorder}
|
||||
<Box
|
||||
className="flxn-avatar-frame"
|
||||
p={withBorder ? size / 20 : 0}
|
||||
style={{
|
||||
// Plain Box, not Paper: the frame must own the avatar's single
|
||||
// border (via --flxn-avatar-border) and stay immune to generic
|
||||
// theme Paper restyles; shape comes from --flxn-avatar-radius.
|
||||
borderRadius: frameRadius,
|
||||
border: withBorder
|
||||
? 'var(--flxn-avatar-border, 1px solid var(--mantine-color-default-border))'
|
||||
: undefined,
|
||||
backgroundColor: withBorder
|
||||
? 'var(--mantine-color-body)'
|
||||
: undefined,
|
||||
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||
transition: 'transform 0.15s ease',
|
||||
}}
|
||||
@@ -67,14 +86,17 @@ const Avatar = ({
|
||||
size={size}
|
||||
radius={radius}
|
||||
w={size}
|
||||
{...props}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: frameRadius,
|
||||
},
|
||||
image: {
|
||||
objectFit: contain ? 'contain' : 'cover',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={isFullscreenOpen}
|
||||
@@ -102,7 +124,7 @@ const Avatar = ({
|
||||
color="dark"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
aria-label="Close image preview"
|
||||
aria-label={t`Close image preview`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useNow from '@/hooks/use-now';
|
||||
import { Text, Group } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import classes from './countdown.module.css';
|
||||
|
||||
interface CountdownProps {
|
||||
@@ -34,6 +35,7 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
|
||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||
|
||||
export function Countdown({ date, label, color }: CountdownProps) {
|
||||
const { t } = useLingui();
|
||||
const now = useNow();
|
||||
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||
|
||||
@@ -45,18 +47,20 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
||||
|
||||
const prefix =
|
||||
timeLeft.days > 0
|
||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
? `${t`${timeLeft.days}d`} ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||
{/* Server and client render different seconds; the first client tick corrects it. */}
|
||||
<Text
|
||||
size='sm'
|
||||
fw={600}
|
||||
c={isFinalStretch ? 'red' : color}
|
||||
ff="monospace"
|
||||
className={classes.time}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{prefix}
|
||||
<span
|
||||
@@ -64,6 +68,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
||||
className={`${classes.seconds} ${
|
||||
isFinalStretch ? classes.finalTick : classes.tick
|
||||
}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{pad(timeLeft.seconds)}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DatePicker, TimeInput } from "@mantine/dates";
|
||||
import { ActionIcon, Stack } from "@mantine/core";
|
||||
import { useRef } from "react";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Date | null;
|
||||
@@ -16,6 +17,7 @@ const DateTimePicker = ({
|
||||
label,
|
||||
...rest
|
||||
}: DateTimePickerProps) => {
|
||||
const { t } = useLingui();
|
||||
const timeRef = useRef<HTMLInputElement>(null);
|
||||
const currentDate = value ? new Date(value) : null;
|
||||
|
||||
@@ -73,7 +75,7 @@ const DateTimePicker = ({
|
||||
/>
|
||||
<TimeInput
|
||||
ref={timeRef}
|
||||
label="Time"
|
||||
label={t`Time`}
|
||||
size="md"
|
||||
value={formatTime(currentDate)}
|
||||
onChange={handleTimeChange}
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Paper, Box } from "@mantine/core";
|
||||
import {
|
||||
Avatar as MantineAvatar,
|
||||
AvatarProps as MantineAvatarProps,
|
||||
} from "@mantine/core";
|
||||
import { Box, Avatar as MantineAvatar } from "@mantine/core";
|
||||
|
||||
interface GlitchAvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
interface GlitchAvatarProps {
|
||||
name: string;
|
||||
src?: string;
|
||||
glitchSrc?: string;
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
withBorder?: boolean;
|
||||
contain?: boolean;
|
||||
children?: React.ReactNode;
|
||||
px?: string | number;
|
||||
frame?: boolean;
|
||||
}
|
||||
|
||||
const FRAME_PADDING = 8;
|
||||
|
||||
const toCssRadius = (radius: string | number) =>
|
||||
typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`;
|
||||
|
||||
const GlitchAvatar = ({
|
||||
name,
|
||||
src,
|
||||
glitchSrc,
|
||||
size = 35,
|
||||
radius = "100%",
|
||||
radius = "md",
|
||||
withBorder = true,
|
||||
contain = false,
|
||||
children,
|
||||
px,
|
||||
frame = false,
|
||||
...props
|
||||
}: GlitchAvatarProps) => {
|
||||
const [showGlitch, setShowGlitch] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -90,39 +83,61 @@ const GlitchAvatar = ({
|
||||
});
|
||||
}, [showGlitch, isPlaying]);
|
||||
|
||||
const innerRadius = toCssRadius(radius);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius:
|
||||
typeof radius === "number"
|
||||
? `${radius + 8}px`
|
||||
: "calc(var(--mantine-radius-md) + 8px)",
|
||||
position: "relative",
|
||||
...(frame && {
|
||||
boxShadow:
|
||||
"0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
|
||||
}),
|
||||
width: "fit-content",
|
||||
padding: FRAME_PADDING,
|
||||
border: withBorder
|
||||
? "1px solid var(--mantine-color-default-border)"
|
||||
: "1px solid transparent",
|
||||
borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{src ? (
|
||||
<Box style={{ position: "relative" }}>
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
style={{
|
||||
display: "block",
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
borderRadius: innerRadius,
|
||||
opacity: showGlitch ? 0 : 1,
|
||||
transition: showGlitch
|
||||
? "opacity 0.05s ease-in"
|
||||
: "opacity 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
py={size / 12.5}
|
||||
px={size / 20}
|
||||
bg="var(--mantine-color-default-border)"
|
||||
radius={radius}
|
||||
withBorder={false}
|
||||
/>
|
||||
{glitchSrc && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={glitchSrc}
|
||||
style={{
|
||||
cursor: "default",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: innerRadius,
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
@@ -131,57 +146,9 @@ const GlitchAvatar = ({
|
||||
size={size}
|
||||
radius={radius}
|
||||
w={size}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: contain ? "contain" : "cover",
|
||||
},
|
||||
}}
|
||||
src={src}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineAvatar>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{glitchSrc && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "8px",
|
||||
left: "8px",
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
py={size / 12.5}
|
||||
px={size / 20}
|
||||
bg="var(--mantine-color-default-border)"
|
||||
radius={radius}
|
||||
withBorder={false}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={glitchSrc}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
objectFit: contain ? "contain" : "cover",
|
||||
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||
display: "block",
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
interface InfiniteScrollProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => ReactNode;
|
||||
batchSize?: number;
|
||||
initialCount?: number;
|
||||
rootMargin?: string;
|
||||
loader?: ReactNode;
|
||||
hasMore?: boolean;
|
||||
loading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
const InfiniteScroll = <T,>({
|
||||
items,
|
||||
renderItem,
|
||||
batchSize = 25,
|
||||
initialCount = batchSize,
|
||||
rootMargin = "600px 0px",
|
||||
loader,
|
||||
hasMore = false,
|
||||
loading = false,
|
||||
onLoadMore,
|
||||
}: InfiniteScrollProps<T>) => {
|
||||
const [visibleCount, setVisibleCount] = useState(initialCount);
|
||||
const [prevItems, setPrevItems] = useState(items);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (prevItems !== items) {
|
||||
setPrevItems(items);
|
||||
setVisibleCount(initialCount);
|
||||
}
|
||||
|
||||
const hasHiddenItems = visibleCount < items.length;
|
||||
const showLoader = hasHiddenItems || hasMore || loading;
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
||||
|
||||
if (visibleCount < items.length) {
|
||||
setVisibleCount((count) => Math.min(count + batchSize, items.length));
|
||||
} else if (hasMore && !loading) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
},
|
||||
{ rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [items, visibleCount, batchSize, hasMore, loading, onLoadMore, rootMargin]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.slice(0, visibleCount).map((item, index) => renderItem(item, index))}
|
||||
{showLoader && (
|
||||
<>
|
||||
<Box ref={sentinelRef} />
|
||||
{loader}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteScroll;
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { track } from '@/lib/telemetry/client'
|
||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile'
|
||||
|
||||
const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))'
|
||||
|
||||
export function IOSInstallPrompt() {
|
||||
const { t } = useLingui()
|
||||
const { user } = useAuth()
|
||||
const isMobile = useIsMobile()
|
||||
const [show, setShow] = useState(false)
|
||||
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||
|
||||
@@ -18,38 +27,47 @@ export function IOSInstallPrompt() {
|
||||
|
||||
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
|
||||
setPlatform(isIOS ? 'ios' : 'android')
|
||||
const timer = setTimeout(() => setShow(true), 3000)
|
||||
const timer = setTimeout(() => {
|
||||
setShow(true)
|
||||
track('pwa_ios_prompt_shown')
|
||||
}, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
|
||||
track('pwa_ios_prompt_dismissed')
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
if (!show || !platform) return null
|
||||
|
||||
const instructions = platform === 'ios'
|
||||
? 'Tap Share → Add to Home Screen'
|
||||
: 'Tap Menu (⋮) → Add to Home screen'
|
||||
? t`Tap Share → Add to Home Screen`
|
||||
: t`Tap Menu (⋮) → Add to Home screen`
|
||||
|
||||
const aboveBottomNav = Boolean(user) && isMobile
|
||||
const bottomStyle = aboveBottomNav
|
||||
? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' }
|
||||
: { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
||||
<Box style={{ position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '8px', ...bottomStyle }}>
|
||||
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||
Please install FLXN • This will save me Twilio credits as you won't be signed out!
|
||||
<Trans>Add FLXN to your home screen for a smoother experience</Trans>
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||
{instructions}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
|
||||
<ActionIcon variant="subtle" color="white" onClick={handleDismiss} aria-label={t`Dismiss`}>
|
||||
<XIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Input, InputProps, Group, Text } from "@mantine/core";
|
||||
import { CheckFat, Phone } from "@phosphor-icons/react";
|
||||
import { IMaskInput } from "react-imask";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface PhoneNumberInputProps extends InputProps {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
||||
error,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<Input.Wrapper
|
||||
id={id}
|
||||
@@ -35,13 +37,13 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
||||
<Group gap={2}>
|
||||
<Phone size={20} /> {" "}
|
||||
<Text c="dimmed" size="sm">
|
||||
+1
|
||||
<Trans>+1</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
leftSectionWidth={50}
|
||||
leftSectionProps={{ style: { padding: 0 } }}
|
||||
placeholder="(713) 867-5309"
|
||||
placeholder={t`(713) 867-5309`}
|
||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
||||
rightSection={
|
||||
value?.length === 10 && (
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
|
||||
import { PropsWithChildren, useEffect, useRef } from "react";
|
||||
import { Drawer as VaulDrawer } from "vaul";
|
||||
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
|
||||
import {
|
||||
pushDimmedThemeColor,
|
||||
popDimmedThemeColor,
|
||||
} from "@/lib/mantine/theme-colors";
|
||||
import { getTheme } from "@/themes/registry";
|
||||
import { resolveChromeColors } from "@/themes/chrome";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface DrawerProps extends PropsWithChildren {
|
||||
@@ -20,22 +26,24 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
onExited,
|
||||
}) => {
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const { metadata } = useAuth();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const themeId = metadata.theme;
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
const appElement = document.querySelector<HTMLElement>(".app");
|
||||
const colors = CHROME_COLORS[colorScheme];
|
||||
const colors = resolveChromeColors(getTheme(themeId))[colorScheme];
|
||||
|
||||
appElement?.classList.add("drawer-scaling");
|
||||
setThemeColorMeta(colors.dimmed);
|
||||
pushDimmedThemeColor(colors.dimmed);
|
||||
|
||||
return () => {
|
||||
appElement?.classList.remove("drawer-scaling");
|
||||
setThemeColorMeta(colors.base);
|
||||
popDimmedThemeColor(colors.base);
|
||||
};
|
||||
}, [opened, colorScheme]);
|
||||
}, [opened, colorScheme, themeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !contentRef.current) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface ModalProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
@@ -15,7 +16,10 @@ const Modal: React.FC<ModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onExited,
|
||||
}) => (
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
@@ -28,7 +32,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
onExited,
|
||||
}}
|
||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||
closeButtonProps={{ "aria-label": "Close" }}
|
||||
closeButtonProps={{ "aria-label": t`Close` }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -40,6 +44,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
{children}
|
||||
</Suspense>
|
||||
</MantineModal>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import Drawer from "./drawer";
|
||||
import Modal from "./modal";
|
||||
import { ScrollArea, Flex, Loader } from "@mantine/core";
|
||||
import { Box, Flex, Loader } from "@mantine/core";
|
||||
|
||||
interface SheetProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
@@ -60,14 +60,19 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
<Loader size='lg' />
|
||||
</Flex>
|
||||
}>
|
||||
<ScrollArea.Autosize
|
||||
style={{ flex: 1, maxHeight: '75dvh' }}
|
||||
scrollbarSize={8}
|
||||
scrollbars="y"
|
||||
type="scroll"
|
||||
{/* Native overflow, not ScrollArea: vaul's drag-vs-scroll detection
|
||||
only recognizes natively scrollable elements. */}
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
maxHeight: "75dvh",
|
||||
overflowY: "auto",
|
||||
overscrollBehavior: "contain",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea.Autosize>
|
||||
</Box>
|
||||
</Suspense>
|
||||
</SheetComponent>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import React, { ComponentType, useContext } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { SlidePanelContext } from "./slide-panel-context";
|
||||
|
||||
interface SlidePanelFieldProps {
|
||||
@@ -23,12 +24,13 @@ const SlidePanelField = ({
|
||||
Component,
|
||||
title,
|
||||
label,
|
||||
placeholder = "Select value",
|
||||
placeholder,
|
||||
withAsterisk = false,
|
||||
formatValue,
|
||||
componentProps,
|
||||
error,
|
||||
}: SlidePanelFieldProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const context = useContext(SlidePanelContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -53,11 +55,11 @@ const SlidePanelField = ({
|
||||
}
|
||||
if (value != null) {
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString();
|
||||
return value.toLocaleDateString(i18n.locale);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return placeholder;
|
||||
return placeholder ?? t`Select value`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { useState, ReactNode } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||
import Button from "@/components/button";
|
||||
|
||||
@@ -28,13 +29,16 @@ const SlidePanel = ({
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitText = "Submit",
|
||||
cancelText = "Cancel",
|
||||
submitText,
|
||||
cancelText,
|
||||
cancelColor = "red",
|
||||
maxHeight = "70vh",
|
||||
formProps = {},
|
||||
loading = false,
|
||||
}: SlidePanelProps) => {
|
||||
const { t } = useLingui();
|
||||
const resolvedSubmitText = submitText ?? t`Submit`;
|
||||
const resolvedCancelText = cancelText ?? t`Cancel`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||
const [tempValue, setTempValue] = useState<any>(null);
|
||||
@@ -113,7 +117,7 @@ const SlidePanel = ({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{submitText}
|
||||
{resolvedSubmitText}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
@@ -124,7 +128,7 @@ const SlidePanel = ({
|
||||
type="button"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -153,7 +157,7 @@ const SlidePanel = ({
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
onClick={closePanel}
|
||||
aria-label="Back"
|
||||
aria-label={t`Back`}
|
||||
>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
@@ -162,7 +166,7 @@ const SlidePanel = ({
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm"
|
||||
aria-label={t`Confirm`}
|
||||
>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
@@ -182,8 +186,8 @@ const SlidePanel = ({
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
<Stack mt="auto" w="100%" gap={2}>
|
||||
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
||||
<Button mt="md" onClick={handleConfirm}><Trans>Confirm</Trans></Button>
|
||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red"><Trans>Cancel</Trans></Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.drawerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
background-color: var(--flxn-overlay);
|
||||
z-index: 101;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { BaseStats } from "@/types/stats";
|
||||
|
||||
interface StatsOverviewProps {
|
||||
@@ -62,11 +63,13 @@ const StatItem = ({
|
||||
};
|
||||
|
||||
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (!statsData && !isLoading) {
|
||||
return (
|
||||
<Box p="sm" h="auto" mih={200}>
|
||||
<Text ta="center" size="sm" fw={600} c="dimmed">
|
||||
No stats available yet
|
||||
<Trans>No stats available yet</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -96,15 +99,15 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
||||
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
|
||||
|
||||
const allStats = [
|
||||
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
|
||||
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
||||
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
||||
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||
{ label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||
{ label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||
{ label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||
{ label: t`Matches Played`, value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||
{ label: t`Wins`, value: overallStats.wins, Icon: CrownIcon },
|
||||
{ label: t`Losses`, value: overallStats.losses, Icon: XIcon },
|
||||
{ label: t`Cups Made`, value: overallStats.total_cups_made, Icon: FireIcon },
|
||||
{ label: t`Cups Against`, value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||
{ label: t`Avg Cups Per Match`, value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||
{ label: t`Avg Cups Against`, value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||
{ label: t`Avg Win Margin`, value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||
{ label: t`Avg Loss Margin`, value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -127,16 +130,17 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
||||
};
|
||||
|
||||
export const StatsSkeleton = () => {
|
||||
const { t } = useLingui();
|
||||
const skeletonStats = [
|
||||
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
||||
{ label: "Wins", Icon: CrownIcon },
|
||||
{ label: "Losses", Icon: XIcon },
|
||||
{ label: "Cups Made", Icon: FireIcon },
|
||||
{ label: "Cups Against", Icon: ShieldIcon },
|
||||
{ label: "Avg Cups Per Match", Icon: ChartLineUpIcon },
|
||||
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
|
||||
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
|
||||
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
|
||||
{ label: t`Matches Played`, Icon: BoxingGloveIcon },
|
||||
{ label: t`Wins`, Icon: CrownIcon },
|
||||
{ label: t`Losses`, Icon: XIcon },
|
||||
{ label: t`Cups Made`, Icon: FireIcon },
|
||||
{ label: t`Cups Against`, Icon: ShieldIcon },
|
||||
{ label: t`Avg Cups Per Match`, Icon: ChartLineUpIcon },
|
||||
{ label: t`Avg Cups Against`, Icon: ShieldCheckIcon },
|
||||
{ label: t`Avg Win Margin`, Icon: ArrowUpIcon },
|
||||
{ label: t`Avg Loss Margin`, Icon: ArrowDownIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,15 +6,19 @@ import {
|
||||
ReactNode,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
startTransition,
|
||||
} from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
/** Stable URL slug for the ?tab= param; defaults to label. Set this when label is translated. */
|
||||
value?: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
const tabValue = (tab: TabItem) => (tab.value ?? tab.label).toLowerCase();
|
||||
|
||||
interface SwipeableTabsProps {
|
||||
tabs: TabItem[];
|
||||
defaultTab?: number;
|
||||
@@ -36,7 +40,7 @@ function SwipeableTabs({
|
||||
const urlTab = search?.tab;
|
||||
if (typeof urlTab === "string") {
|
||||
const tabIndex = tabs.findIndex(
|
||||
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
|
||||
(tab) => tabValue(tab) === urlTab.toLowerCase()
|
||||
);
|
||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||
}
|
||||
@@ -44,6 +48,11 @@ function SwipeableTabs({
|
||||
}, [search?.tab, tabs, defaultTab]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
|
||||
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
|
||||
() => new Set([getActiveTabFromUrl()])
|
||||
);
|
||||
const [stuck, setStuck] = useState(false);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
|
||||
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||
@@ -54,6 +63,9 @@ function SwipeableTabs({
|
||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||
|
||||
setActiveTab(index);
|
||||
startTransition(() => {
|
||||
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
|
||||
});
|
||||
embla?.scrollTo(index);
|
||||
onTabChange?.(index, tabs[index]);
|
||||
|
||||
@@ -62,7 +74,7 @@ function SwipeableTabs({
|
||||
?.querySelector(".mantine-ScrollArea-viewport")
|
||||
?.scrollTo({ top: 0 });
|
||||
|
||||
const tabLabel = tabs[index].label.toLowerCase();
|
||||
const tabLabel = tabValue(tabs[index]);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabLabel);
|
||||
@@ -128,6 +140,20 @@ function SwipeableTabs({
|
||||
};
|
||||
}, [activeTab, updateHeight]);
|
||||
|
||||
// A 1px sentinel above the sticky strip: once it scrolls out of view the
|
||||
// strip is pinned — flip data-stuck so themes can swap flat → docked chrome.
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setStuck(!entry.isIntersecting),
|
||||
{ threshold: 0 }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const setControlRef = useCallback(
|
||||
(index: number) => (node: HTMLSpanElement | null) => {
|
||||
controlsRefs.current[index] = node;
|
||||
@@ -144,8 +170,11 @@ function SwipeableTabs({
|
||||
|
||||
return (
|
||||
<Box style={{ touchAction: "pan-y" }}>
|
||||
<div ref={sentinelRef} aria-hidden style={{ height: 1, marginTop: -1 }} />
|
||||
<Box
|
||||
ref={setRootRef}
|
||||
className="flxn-tabs"
|
||||
data-stuck={stuck || undefined}
|
||||
pos="sticky"
|
||||
top={0}
|
||||
style={{
|
||||
@@ -153,45 +182,28 @@ function SwipeableTabs({
|
||||
paddingInline: "var(--mantine-spacing-md)",
|
||||
marginBottom: mb !== undefined ? mb : "var(--mantine-spacing-md)",
|
||||
zIndex: 100,
|
||||
backgroundColor: "var(--mantine-color-body)",
|
||||
}}
|
||||
>
|
||||
<FloatingIndicator
|
||||
target={controlsRefs.current[activeTab]}
|
||||
parent={rootRef}
|
||||
styles={{
|
||||
root: {
|
||||
borderBottom: "2px solid var(--mantine-primary-color-filled)",
|
||||
paddingInline: "0.5rem",
|
||||
},
|
||||
}}
|
||||
className="flxn-tabs-indicator"
|
||||
/>
|
||||
{tabs.map((tab, index) => (
|
||||
<UnstyledButton
|
||||
key={`${tab.label}-${index}`}
|
||||
onClick={() => changeTab(index)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-xs)",
|
||||
textAlign: "center",
|
||||
color:
|
||||
activeTab === index
|
||||
? "var(--mantine-primary-color-filled)"
|
||||
: "var(--mantine-color-text)",
|
||||
fontWeight: activeTab === index ? 600 : 400,
|
||||
transition: "color 200ms ease, font-weight 200ms ease",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
className="flxn-tab"
|
||||
data-active={activeTab === index || undefined}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
component="span"
|
||||
style={{
|
||||
// the FloatingIndicator sizes to this span — keep padding symmetric
|
||||
display: "inline-block",
|
||||
paddingInline: "0.5rem",
|
||||
paddingBottom: "0.25rem",
|
||||
paddingInline: "0.875rem",
|
||||
paddingBlock: "0.375rem",
|
||||
}}
|
||||
ref={setControlRef(index)}
|
||||
>
|
||||
@@ -219,7 +231,7 @@ function SwipeableTabs({
|
||||
{tabs.map((tab, index) => (
|
||||
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
|
||||
{tab.content}
|
||||
{(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
|
||||
@@ -62,7 +62,7 @@ const TeamAvatar = ({
|
||||
top: -crownSize * 1.1,
|
||||
left: "45%",
|
||||
transform: "translateX(-50%)",
|
||||
color: "gold",
|
||||
color: "var(--flxn-gold)",
|
||||
rotate: "-5deg",
|
||||
}}
|
||||
>
|
||||
@@ -83,7 +83,7 @@ const TeamAvatar = ({
|
||||
top: -crownSize * 0.95,
|
||||
left: "65%",
|
||||
transform: "translateX(-50%)",
|
||||
color: "gold",
|
||||
color: "var(--flxn-gold)",
|
||||
rotate: "10deg",
|
||||
}}
|
||||
>
|
||||
@@ -116,7 +116,7 @@ const TeamAvatar = ({
|
||||
top: -crownSize * 0.6,
|
||||
left: -crownSize * 0.25,
|
||||
transform: "rotate(-25deg)",
|
||||
color: "gold",
|
||||
color: "var(--flxn-gold)",
|
||||
}}
|
||||
>
|
||||
<CrownIcon size={crownSize} weight="fill" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
export interface TypeaheadOption<T = any> {
|
||||
id: string;
|
||||
@@ -24,12 +25,14 @@ const Typeahead = <T,>({
|
||||
searchFn,
|
||||
renderOption,
|
||||
format,
|
||||
placeholder = "Search...",
|
||||
placeholder,
|
||||
debounceMs = 300,
|
||||
disabled = false,
|
||||
initialValue = "",
|
||||
maxHeight = 200,
|
||||
}: TypeaheadProps<T>) => {
|
||||
const { t } = useLingui();
|
||||
const resolvedPlaceholder = placeholder ?? t`Search...`;
|
||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -119,7 +122,7 @@ const Typeahead = <T,>({
|
||||
}
|
||||
await performSearch(searchQuery);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -164,7 +167,7 @@ const Typeahead = <T,>({
|
||||
) : (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
|
||||
{searchQuery.trim() ? t`No results found` : t`Start typing to search...`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { Icon, IconProps } from "@phosphor-icons/react";
|
||||
|
||||
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
|
||||
(
|
||||
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
|
||||
ref
|
||||
) => {
|
||||
const strokeWidth = weight === "bold" ? 20 : 16;
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
style={{
|
||||
transform: mirrored ? "scale(-1, 1)" : undefined,
|
||||
...style,
|
||||
}}
|
||||
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
||||
{...rest}
|
||||
>
|
||||
<circle
|
||||
cx="128"
|
||||
cy="112"
|
||||
r="66"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill={weight === "duotone" ? color : "none"}
|
||||
fillOpacity={weight === "duotone" ? 0.18 : undefined}
|
||||
/>
|
||||
<path
|
||||
d="M94 96 a 44 44 0 0 1 30 -26"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth * 0.7}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M96 194 h64 l16 26 h-96 z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
) as Icon;
|
||||
|
||||
export default WizardOrbIcon;
|
||||
@@ -12,7 +12,14 @@ import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
|
||||
|
||||
interface AuthData {
|
||||
user: Player | undefined;
|
||||
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
|
||||
metadata: {
|
||||
accentColor: MantineColor;
|
||||
colorScheme: MantineColorScheme;
|
||||
theme?: string;
|
||||
locale?: string;
|
||||
seenThemesHint?: boolean;
|
||||
telemetryAlerts?: boolean;
|
||||
};
|
||||
roles: string[];
|
||||
phone: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
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 type {
|
||||
SpotifyContextType,
|
||||
@@ -38,7 +39,13 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const config = useConfig();
|
||||
const { t } = useLingui();
|
||||
|
||||
// Defined inside the provider so error messages can be localized via `t`.
|
||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
@@ -49,12 +56,12 @@ const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) =
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
let errorMessage = t`Request failed`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
errorMessage = t`HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
@@ -75,12 +82,7 @@ const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) =
|
||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const config = useConfig();
|
||||
};
|
||||
|
||||
const [authState, setAuthState] = useState<SpotifyAuthState>(defaultSpotifyState);
|
||||
|
||||
@@ -150,10 +152,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
let errorMessage = `Authentication failed: ${error}`;
|
||||
if (details) {
|
||||
errorMessage += ` - ${decodeURIComponent(details)}`;
|
||||
}
|
||||
const errorMessage = details
|
||||
? t`Authentication failed: ${error} - ${decodeURIComponent(details)}`
|
||||
: t`Authentication failed: ${error}`;
|
||||
setError(errorMessage);
|
||||
|
||||
console.error('Spotify OAuth Error:', { error, details });
|
||||
@@ -203,7 +204,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -226,7 +227,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const pause = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -249,7 +250,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const skipNext = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -272,7 +273,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const skipPrevious = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -295,7 +296,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const setVolume = useCallback(async (volumePercent: number) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -309,11 +310,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
body: JSON.stringify({ action: 'volume', volumePercent }),
|
||||
});
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to set volume');
|
||||
setError(error instanceof Error ? error.message : t`Failed to set volume`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const getDevices = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -330,11 +331,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
setActiveDeviceState(active);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to get devices');
|
||||
setError(error instanceof Error ? error.message : t`Failed to get devices`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const setActiveDevice = useCallback(async (deviceId: string) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -355,11 +356,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
setTimeout(getDevices, 1000);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to set active device');
|
||||
setError(error instanceof Error ? error.message : t`Failed to set active device`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated, devices]);
|
||||
}, [authState.isAuthenticated, devices, t]);
|
||||
|
||||
const refreshPlaybackState = useCallback(async () => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -382,7 +383,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh playback state:', error);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
@@ -422,11 +423,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
setCapturedState(response.snapshot);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to capture playback state');
|
||||
setError(error instanceof Error ? error.message : t`Failed to capture playback state`);
|
||||
} finally {
|
||||
setIsCaptureLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated]);
|
||||
}, [authState.isAuthenticated, t]);
|
||||
|
||||
const resumePlaybackState = useCallback(async () => {
|
||||
if (!authState.isAuthenticated || !capturedState) return;
|
||||
@@ -442,11 +443,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
setTimeout(refreshPlaybackState, 1000);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Failed to resume playback state');
|
||||
setError(error instanceof Error ? error.message : t`Failed to resume playback state`);
|
||||
} finally {
|
||||
setIsResumeLoading(false);
|
||||
}
|
||||
}, [authState.isAuthenticated, capturedState, refreshPlaybackState]);
|
||||
}, [authState.isAuthenticated, capturedState, refreshPlaybackState, t]);
|
||||
|
||||
const clearCapturedState = useCallback(() => {
|
||||
setCapturedState(null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user