23 Commits
Author SHA1 Message Date
yohlo 19dc698f46 i18n ci fix
CI/CD Pipeline / i18n Catalog Check (push) Failing after 9s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-08 16:00:48 -07:00
yohlo 0a7b5fa00f i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped
2026-08-08 15:29:40 -07:00
yohlo 22c282d8fa more for the overhaul
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m49s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 30s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 9m40s
2026-07-22 16:41:44 -07:00
yohlo 86cda294f9 social overhaul 2026-07-22 15:47:34 -07:00
yohlo 9db3fa697d Merge branch 'loader' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m55s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-14 15:00:50 -07:00
yohlo 6565714ee4 Merge branch 'predictions' into development
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-14 14:22:50 -07:00
yohlo 1783f0e6bc predictions 2026-07-14 14:15:34 -07:00
yohlo 0a7f4de321 Merge branch 'style' into development 2026-07-14 00:39:43 -07:00
yohlo e8b7647f3d styling 2026-07-14 00:39:38 -07:00
yohlo 0284b33e50 Merge branch 'style' into development 2026-07-14 00:12:46 -07:00
yohlo d3809b5805 styling 2026-07-14 00:12:39 -07:00
yohlo a50f9b6644 pb 2026-07-13 23:59:10 -07:00
yohlo f77b3a5c41 infinite scroll 2026-07-13 23:57:31 -07:00
yohlo 3b087850e7 Merge branch 'perf' into development 2026-07-13 23:56:30 -07:00
yohlo 19f61ac454 SSE 2026-07-13 23:56:19 -07:00
yohlo e6c72a5789 more styles
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m36s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m23s
2026-07-12 23:23:14 -07:00
yohlo e9040bb02f generate sw
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m38s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m24s
2026-07-12 21:51:48 -07:00
yohlo 552525d272 remove elevated header
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:43:41 -07:00
yohlo 4420e4d0fe merge
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 1m54s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
2026-07-12 21:30:51 -07:00
yohlo 3cc16bdeb2 Merge branch 'style' into development 2026-07-12 21:30:02 -07:00
yohlo 48eb7d71b2 Merge branch 'perf' into development 2026-07-12 21:28:46 -07:00
yohlo 4f81f3c0ca style upgrades 2026-07-12 21:26:39 -07:00
yohlo 778f0f7994 perf upgrade 2026-07-12 21:26:16 -07:00
308 changed files with 22062 additions and 3193 deletions
+56
View File
@@ -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.
+101
View File
@@ -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.
+41
View File
@@ -7,9 +7,50 @@ on:
- main - main
jobs: jobs:
i18n-check:
name: i18n Catalog Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Bun
run: |
if ! command -v unzip >/dev/null; then
(command -v apt-get >/dev/null && apt-get update && apt-get install -y unzip) || \
(command -v apk >/dev/null && apk add --no-cache unzip)
fi
arch=$(uname -m)
case "$arch" in x86_64) target=x64 ;; aarch64|arm64) target=aarch64 ;; *) echo "unsupported arch: $arch"; exit 1 ;; esac
# musl images (Alpine) need the -musl build; the glibc one fails with spawn ENOENT
libc=""
if ldd --version 2>&1 | grep -qi musl; then libc="-musl"; fi
curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${target}${libc}.zip"
unzip -q -o /tmp/bun.zip -d /tmp/bun-extract
mkdir -p "$HOME/.bun/bin"
mv /tmp/bun-extract/bun-linux-*/bun "$HOME/.bun/bin/bun"
chmod +x "$HOME/.bun/bin/bun"
echo "$HOME/.bun/bin" >> $GITHUB_PATH
"$HOME/.bun/bin/bun" --version
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Catalogs in sync with source
run: |
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: build-app:
name: Build and Push App Docker Image name: Build and Push App Docker Image
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: i18n-check
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
+3 -1
View File
@@ -17,7 +17,9 @@ yarn.lock
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/scripts/ /_scripts/
/pb_data/ /pb_data/
/.tanstack/ /.tanstack/
/dist/ /dist/
# lingui compile output (i18n:check); runtime uses .po via vite plugin
src/locales/*/messages.js
+23
View File
@@ -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).
+1 -2
View File
@@ -2,7 +2,7 @@ FROM oven/bun:1 AS builder
WORKDIR /app WORKDIR /app
COPY package.json bun.lockb* ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -21,7 +21,6 @@ EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV NITRO_PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
+772 -494
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -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):
- **WL + 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 41 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, 120350ms), 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.
+91
View File
@@ -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` (~L189216); `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` (~L828928). 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` (L341381) 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` (L4574): 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 (L156162) 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 120350ms 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` (L341381) 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.
+89
View File
@@ -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 385397) | 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 24 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 36 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 | 02 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.
+84
View File
@@ -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, 120350ms) — 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.
+1 -1
View File
@@ -6,7 +6,7 @@ metadata:
app: flxn app: flxn
component: app component: app
spec: spec:
replicas: 1 replicas: 1 # Must stay at 1 for SSE
selector: selector:
matchLabels: matchLabels:
app: flxn app: flxn
+13
View File
@@ -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 -27
View File
@@ -5,65 +5,62 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0", "dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit", "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" "start": "bun run server.ts"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@lingui/core": "^6.5.0",
"@lingui/react": "^6.5.0",
"@mantine/carousel": "^8.2.4", "@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4", "@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4", "@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4", "@mantine/form": "^8.2.4",
"@mantine/hooks": "^8.2.4", "@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4", "@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-devtools": "^0.7.6", "@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router": "^1.143.6", "@tanstack/react-router": "^1.170.17",
"@tanstack/react-router-devtools": "^1.143.6", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.143.6", "@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.143.6", "@tanstack/react-start": "^1.168.27",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3", "@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3", "@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3", "@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22", "@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.0.0", "react": "^19.2.7",
"react-dom": "^19.0.0", "react-dom": "^19.2.7",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"supertokens-node": "^23.0.1", "supertokens-node": "^23.0.1",
"supertokens-web-js": "^0.15.0", "supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5", "web-push": "^3.6.7",
"zod": "^4.0.15", "zod": "^4.0.15"
"zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-plugin": "^1.132.2", "@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/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/react": "^19.2.17",
"@types/react": "^19.0.8", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.0.3", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
@@ -71,8 +68,7 @@
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"workbox-window": "^7.4.0" "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);
})
+58
View File
@@ -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);
}
})
);
});
+2 -2
View File
@@ -18,8 +18,8 @@
], ],
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"theme_color": "#1e293b", "theme_color": "#242424",
"background_color": "#0f172a", "background_color": "#242424",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/",
"categories": ["games", "social", "beer pong"], "categories": ["games", "social", "beer pong"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+24
View File
@@ -29,3 +29,27 @@
[data-drawer-level="3"].drawer-scaling { [data-drawer-level="3"].drawer-scaling {
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
} }
@media (prefers-reduced-motion: reduce) {
.app,
[data-drawer-level] {
transition: none;
}
.app.drawer-scaling,
[data-drawer-level].drawer-scaling {
transform: none;
border-radius: 0;
}
[data-vaul-drawer],
[data-vaul-overlay] {
animation: none !important;
transition: none !important;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(0deg); }
}
}
+79
View File
@@ -0,0 +1,79 @@
import { generateSW } from 'workbox-build'
const ONE_YEAR = 60 * 60 * 24 * 365
const THIRTY_DAYS = 60 * 60 * 24 * 30
const { count, size, warnings } = await generateSW({
swDest: 'dist/client/sw.js',
globDirectory: 'dist/client',
globPatterns: [
'assets/**/*.{js,css}',
'favicon*.{ico,png}',
'apple-touch-icon.png',
'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,
cleanupOutdatedCaches: true,
sourcemap: false,
runtimeCaching: [
{
urlPattern: ({ url, request }) =>
url.origin === self.location.origin && request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: {
maxEntries: 60,
maxAgeSeconds: THIRTY_DAYS,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: ONE_YEAR,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
})
for (const warning of warnings) console.warn(warning)
console.log(
`sw.js generated: precached ${count} files, ${(size / 1024).toFixed(1)} KiB`,
)
+81
View File
@@ -0,0 +1,81 @@
// Grant or revoke the "Admin" role for a user by phone number (US +1 assumed).
// Usage: bun run scripts/make-admin.ts <phone> [--revoke]
import "dotenv/config";
import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
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 raw = process.argv[2];
const revoke = process.argv.includes("--revoke");
if (!raw) {
console.error("Usage: bun run scripts/make-admin.ts <phone> [--revoke]");
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("Admin", []);
if (revoke) {
const res = await UserRoles.removeUserRole("public", user.id, "Admin");
console.log(`Removed Admin from ${matched} (user ${user.id}):`, res.status);
} else {
const res = await UserRoles.addRoleToUser("public", user.id, "Admin");
console.log(
`Granted Admin 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);
+7 -9
View File
@@ -343,7 +343,10 @@ async function initializeStaticRoutes(
gz, gz,
etag, etag,
type: metadata.type, type: metadata.type,
immutable: true, // Only Vite's content-hashed output under /assets/ is safe to
// cache immutably; unhashed files (favicon, manifest, images)
// can change between deploys and must revalidate.
immutable: route.startsWith('/assets/'),
size: bytes.byteLength, size: bytes.byteLength,
} }
routes[route] = createResponseHandler(asset) routes[route] = createResponseHandler(asset)
@@ -529,14 +532,9 @@ async function initializeServer() {
// Fallback to TanStack Start handler for all other routes // Fallback to TanStack Start handler for all other routes
'/*': async (req: Request) => { '/*': async (req: Request) => {
try { try {
const h3Response = await handler.fetch(req) // Return the handler's Response as-is so streaming bodies
// (SSR streaming, SSE event streams) pass through unbuffered.
const body = await h3Response.arrayBuffer() return await handler.fetch(req)
return new Response(body, {
status: h3Response.status,
statusText: h3Response.statusText,
headers: h3Response.headers,
})
} catch (error) { } catch (error) {
log.error(`Server handler error: ${String(error)}`) log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 }) return new Response('Internal Server Error', { status: 500 })
+131
View File
@@ -29,6 +29,9 @@ import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resum
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback' import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture' import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback' 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 ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
@@ -38,10 +41,13 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdPredictionsRouteImport } from './routes/_authed/tournaments/$id.predictions'
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedTournamentsIdPredictionsMakeRouteImport } from './routes/_authed/tournaments/$id.predictions_.make'
import { Route as AuthedTournamentsIdPredictionsPlayerIdRouteImport } from './routes/_authed/tournaments/$id.predictions_.$playerId'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners'
@@ -146,6 +152,21 @@ const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
path: '/api/spotify/callback', path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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({ const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$', id: '/api/events/$',
path: '/api/events/$', path: '/api/events/$',
@@ -193,6 +214,12 @@ const AuthedAdminTournamentsIndexRoute =
path: '/tournaments/', path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedTournamentsIdPredictionsRoute =
AuthedTournamentsIdPredictionsRouteImport.update({
id: '/tournaments/$id/predictions',
path: '/tournaments/$id/predictions',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIdGroupsRoute = const AuthedTournamentsIdGroupsRoute =
AuthedTournamentsIdGroupsRouteImport.update({ AuthedTournamentsIdGroupsRouteImport.update({
id: '/tournaments/$id/groups', id: '/tournaments/$id/groups',
@@ -217,6 +244,18 @@ const ApiFilesCollectionRecordIdFileRoute =
path: '/api/files/$collection/$recordId/$file', path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } 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 = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
@@ -254,6 +293,9 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute '/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/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute '/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -266,10 +308,13 @@ export interface FileRoutesByFullPath {
'/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -290,6 +335,9 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute '/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/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute '/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -302,10 +350,13 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/tournaments/$id/predictions/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/tournaments/$id/predictions/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -329,6 +380,9 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute '/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/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute '/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute '/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
@@ -341,10 +395,13 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
'/_authed/tournaments/$id/predictions': typeof AuthedTournamentsIdPredictionsRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/_authed/tournaments/$id/predictions_/$playerId': typeof AuthedTournamentsIdPredictionsPlayerIdRoute
'/_authed/tournaments/$id/predictions_/make': typeof AuthedTournamentsIdPredictionsMakeRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
@@ -368,6 +425,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback' | '/api/spotify/callback'
| '/api/spotify/capture' | '/api/spotify/capture'
| '/api/spotify/playback' | '/api/spotify/playback'
@@ -380,10 +440,13 @@ export interface FileRouteTypes {
| '/tournaments/' | '/tournaments/'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments/' | '/admin/tournaments/'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id/' | '/admin/tournaments/$id/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
@@ -404,6 +467,9 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback' | '/api/spotify/callback'
| '/api/spotify/capture' | '/api/spotify/capture'
| '/api/spotify/playback' | '/api/spotify/playback'
@@ -416,10 +482,13 @@ export interface FileRouteTypes {
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/tournaments/$id/groups' | '/tournaments/$id/groups'
| '/tournaments/$id/predictions'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/assign-partners'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/tournaments/$id/predictions/$playerId'
| '/tournaments/$id/predictions/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
id: id:
@@ -442,6 +511,9 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/api/auth/$' | '/api/auth/$'
| '/api/events/$' | '/api/events/$'
| '/api/push/subscribe'
| '/api/push/test'
| '/api/push/unsubscribe'
| '/api/spotify/callback' | '/api/spotify/callback'
| '/api/spotify/capture' | '/api/spotify/capture'
| '/api/spotify/playback' | '/api/spotify/playback'
@@ -454,10 +526,13 @@ export interface FileRouteTypes {
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/tournaments/$id/groups' | '/_authed/tournaments/$id/groups'
| '/_authed/tournaments/$id/predictions'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/assign-partners'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/_authed/tournaments/$id/predictions_/$playerId'
| '/_authed/tournaments/$id/predictions_/make'
| '/api/files/$collection/$recordId/$file' | '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/' | '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -470,6 +545,9 @@ export interface RootRouteChildren {
ApiHealthRoute: typeof ApiHealthRoute ApiHealthRoute: typeof ApiHealthRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiPushSubscribeRoute: typeof ApiPushSubscribeRoute
ApiPushTestRoute: typeof ApiPushTestRoute
ApiPushUnsubscribeRoute: typeof ApiPushUnsubscribeRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
@@ -623,6 +701,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport 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/$': { '/api/events/$': {
id: '/api/events/$' id: '/api/events/$'
path: '/api/events/$' path: '/api/events/$'
@@ -686,6 +785,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_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': { '/_authed/tournaments/$id/groups': {
id: '/_authed/tournaments/$id/groups' id: '/_authed/tournaments/$id/groups'
path: '/tournaments/$id/groups' path: '/tournaments/$id/groups'
@@ -714,6 +820,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport 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': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -779,6 +899,9 @@ interface AuthedRouteChildren {
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
AuthedTournamentsIdPredictionsRoute: typeof AuthedTournamentsIdPredictionsRoute
AuthedTournamentsIdPredictionsPlayerIdRoute: typeof AuthedTournamentsIdPredictionsPlayerIdRoute
AuthedTournamentsIdPredictionsMakeRoute: typeof AuthedTournamentsIdPredictionsMakeRoute
} }
const AuthedRouteChildren: AuthedRouteChildren = { const AuthedRouteChildren: AuthedRouteChildren = {
@@ -793,6 +916,11 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
AuthedTournamentsIdPredictionsRoute: AuthedTournamentsIdPredictionsRoute,
AuthedTournamentsIdPredictionsPlayerIdRoute:
AuthedTournamentsIdPredictionsPlayerIdRoute,
AuthedTournamentsIdPredictionsMakeRoute:
AuthedTournamentsIdPredictionsMakeRoute,
} }
const AuthedRouteWithChildren = const AuthedRouteWithChildren =
@@ -806,6 +934,9 @@ const rootRouteChildren: RootRouteChildren = {
ApiHealthRoute: ApiHealthRoute, ApiHealthRoute: ApiHealthRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute, ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiPushSubscribeRoute: ApiPushSubscribeRoute,
ApiPushTestRoute: ApiPushTestRoute,
ApiPushUnsubscribeRoute: ApiPushUnsubscribeRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute, ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute, ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute, ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
+1 -2
View File
@@ -29,9 +29,8 @@ export function getRouter() {
fullWidth: false, fullWidth: false,
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
}); });
setupRouterSsrQueryIntegration({ setupRouterSsrQueryIntegration({
+93 -69
View File
@@ -18,7 +18,10 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n";
import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
@@ -32,75 +35,81 @@ export const Route = createRootRouteWithContext<{
withPadding: boolean; withPadding: boolean;
fullWidth: boolean; fullWidth: boolean;
}>()({ }>()({
head: () => ({ head: () => {
title: "FLXN IX", // Meta stays DEFAULT_LOCALE: crawlers/unfurlers have no session, and
meta: [ // reading match.context here creates circular route-type inference that
{ // breaks beforeLoad typing in child routes.
charSet: "utf-8", const rootMeta = getRootMeta(DEFAULT_LOCALE);
},
{ return {
name: "viewport", title: "FLXN IX",
content: meta: [
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content", {
}, charSet: "utf-8",
{ name: 'description', content: 'Amicus meus madidus' }, },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' }, {
{ name: 'theme-color', content: '#1e293b' }, name: "viewport",
{ property: 'og:title', content: 'FLXN' }, content:
{ property: 'og:description', content: 'Amicus meus madidus' }, "width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
{ property: 'og:url', content: 'https://flexxon.app' }, },
{ property: 'og:type', content: 'website' }, { name: 'description', content: rootMeta.description },
{ property: 'og:site_name', content: 'FLXN' }, { name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ property: 'og:image', content: 'https://flexxon.app/favicon.png' }, { property: 'og:title', content: 'FLXN' },
{ property: 'og:image:width', content: '512' }, { property: 'og:description', content: rootMeta.description },
{ property: 'og:image:height', content: '512' }, { property: 'og:url', content: 'https://flexxon.app' },
{ property: 'og:image:alt', content: 'FLXN logo' }, { property: 'og:type', content: 'website' },
{ property: 'og:locale', content: 'en_US' }, { property: 'og:site_name', content: 'FLXN' },
{ name: 'twitter:card', content: 'summary' }, { property: 'og:image', content: 'https://flexxon.app/favicon.png' },
{ name: 'twitter:title', content: 'FLXN' }, { property: 'og:image:width', content: '512' },
{ name: 'twitter:description', content: 'Amicus meus madidus' }, { property: 'og:image:height', content: '512' },
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' }, { property: 'og:image:alt', content: rootMeta.ogImageAlt },
{ name: 'mobile-web-app-capable', content: 'yes' }, { property: 'og:locale', content: rootMeta.ogLocale },
{ name: 'apple-mobile-web-app-capable', content: 'yes' }, { name: 'twitter:card', content: 'summary' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, { name: 'twitter:title', content: 'FLXN' },
{ name: 'apple-mobile-web-app-title', content: 'FLXN' }, { name: 'twitter:description', content: rootMeta.description },
], { name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
links: [ { name: 'mobile-web-app-capable', content: 'yes' },
{ { name: 'apple-mobile-web-app-capable', content: 'yes' },
rel: "apple-touch-icon", { name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
sizes: "180x180", { name: 'apple-mobile-web-app-title', content: 'FLXN' },
href: "/apple-touch-icon.png", ],
}, links: [
{ {
rel: "icon", rel: "apple-touch-icon",
type: "image/png", sizes: "180x180",
sizes: "32x32", href: "/apple-touch-icon.png",
href: "/favicon-32x32.png", },
}, {
{ rel: "icon",
rel: "icon", type: "image/png",
type: "image/png", sizes: "32x32",
sizes: "16x16", href: "/favicon-32x32.png",
href: "/favicon-16x16.png", },
}, {
{ rel: "manifest", href: "/site.webmanifest" }, rel: "icon",
{ rel: "icon", href: "/favicon.ico" }, type: "image/png",
{ rel: 'stylesheet', href: mantineCssUrl }, sizes: "16x16",
{ rel: 'stylesheet', href: mantineCarouselCssUrl }, href: "/favicon-16x16.png",
{ rel: 'stylesheet', href: mantineDatesCssUrl }, },
{ rel: 'stylesheet', href: mantineTiptapCssUrl }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "icon", href: "/favicon.ico" },
{ { rel: 'stylesheet', href: mantineCssUrl },
rel: "preconnect", { rel: 'stylesheet', href: mantineCarouselCssUrl },
href: "https://fonts.gstatic.com", { rel: 'stylesheet', href: mantineDatesCssUrl },
crossOrigin: "anonymous", { rel: 'stylesheet', href: mantineTiptapCssUrl },
}, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ {
rel: "stylesheet", rel: "preconnect",
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", href: "https://fonts.gstatic.com",
} crossOrigin: "anonymous",
], },
}), {
rel: "stylesheet",
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) => { errorComponent: (props) => {
return ( return (
<RootDocument> <RootDocument>
@@ -123,6 +132,7 @@ export const Route = createRootRouteWithContext<{
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
await ensureMessages(resolveLocale(auth?.metadata?.locale));
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
if (isRedirect(error) || error instanceof Response) throw error; if (isRedirect(error) || error instanceof Response) throw error;
@@ -138,6 +148,7 @@ export const Route = createRootRouteWithContext<{
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
await ensureMessages(resolveLocale(auth?.metadata?.locale));
return { auth }; return { auth };
} catch { } catch {
return {}; return {};
@@ -153,6 +164,9 @@ export const Route = createRootRouteWithContext<{
function RootComponent() { function RootComponent() {
React.useEffect(() => { React.useEffect(() => {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, []); }, []);
return ( return (
@@ -179,6 +193,16 @@ function RootDocument({ children }: { children: React.ReactNode }) {
> >
<head> <head>
<HeadContent /> <HeadContent />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content={CHROME_COLORS.light.base}
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content={CHROME_COLORS.dark.base}
/>
<ColorSchemeScript /> <ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
+16 -4
View File
@@ -1,7 +1,7 @@
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router"; import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout"; import Layout from "@/features/core/components/layout";
import { useServerEvents } from "@/hooks/use-server-events"; import { useServerEvents } from "@/hooks/use-server-events";
import { Flex, Loader } from "@mantine/core"; import { Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed")({ export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
@@ -26,9 +26,21 @@ export const Route = createFileRoute("/_authed")({
}, },
pendingComponent: () => ( pendingComponent: () => (
<Layout> <Layout>
<Flex w='100%' h="40dvh" justify="center" align="flex-end"> <Stack gap="md" p="md" w="100%">
<Loader size='xl' /> <Group gap="sm">
</Flex> <Skeleton height={40} width={40} radius="sm" />
<Skeleton height={24} width="45%" radius="sm" />
</Group>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`authed-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.18, 0.3) }}
/>
))}
</Stack>
</Layout> </Layout>
), ),
}); });
+8 -6
View File
@@ -1,4 +1,6 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router"; import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import type { HeaderConfig } from "@/features/core/types/header-config";
export const Route = createFileRoute("/_authed/admin")({ export const Route = createFileRoute("/_authed/admin")({
component: Outlet, component: Outlet,
@@ -7,12 +9,12 @@ export const Route = createFileRoute("/_authed/admin")({
throw redirect({ to: "/" }); throw redirect({ to: "/" });
} }
return { const header: HeaderConfig = {
header: { ...context.header,
...context.header, title: msg`Admin`,
title: "Admin", withBackButton: true,
withBackButton: true,
},
}; };
return { header };
}, },
}); });
+37 -7
View File
@@ -2,8 +2,10 @@ import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities"; import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players"; import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Tabs } from "@mantine/core"; import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
import { useState } from "react"; import { Suspense, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/activities")({ export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats, component: Stats,
@@ -16,29 +18,57 @@ export const Route = createFileRoute("/_authed/admin/activities")({
withPadding: false, withPadding: false,
fullWidth: true, fullWidth: true,
header: { header: {
title: "Activities", title: msg`Activities`,
withBackButton: true, withBackButton: true,
}, },
refresh: [activityQueries.search().queryKey, playerQueries.activity().queryKey], 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() { function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions"); const [activeTab, setActiveTab] = useState<string | null>("server-functions");
return ( return (
<Tabs value={activeTab} onChange={setActiveTab}> <Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List mb='md'> <Tabs.List mb='md'>
<Tabs.Tab value="server-functions">Server Functions</Tabs.Tab> <Tabs.Tab value="server-functions"><Trans>Server Functions</Trans></Tabs.Tab>
<Tabs.Tab value="player-activity">Player Activity</Tabs.Tab> <Tabs.Tab value="player-activity"><Trans>Player Activity</Trans></Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="server-functions"> <Tabs.Panel value="server-functions">
<ActivitiesTable /> <Suspense fallback={<ActivityRowsSkeleton withSearch />}>
<ActivitiesTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="player-activity"> <Tabs.Panel value="player-activity">
<PlayersActivityTable /> <Suspense fallback={<ActivityRowsSkeleton />}>
<PlayersActivityTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
); );
+2 -1
View File
@@ -1,11 +1,12 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AdminPage } from "@/features/admin"; import { AdminPage } from "@/features/admin";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/")({ export const Route = createFileRoute("/_authed/admin/")({
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: "Admin", title: msg`Admin`,
}, },
withPadding: false, withPadding: false,
}), }),
+5 -2
View File
@@ -2,13 +2,15 @@ import BracketPreview from "@/features/admin/components/preview";
import { NumberInput } from "@mantine/core"; import { NumberInput } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/preview")({ export const Route = createFileRoute("/_authed/admin/preview")({
component: RouteComponent, component: RouteComponent,
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: "Bracket Preview", title: msg`Bracket Preview`,
}, },
withPadding: false, withPadding: false,
fullWidth: true, fullWidth: true,
@@ -16,13 +18,14 @@ export const Route = createFileRoute("/_authed/admin/preview")({
}); });
function RouteComponent() { function RouteComponent() {
const { t } = useLingui();
const [n, setN] = useState(16); const [n, setN] = useState(16);
return ( return (
<> <>
<NumberInput <NumberInput
min={9} min={9}
max={27} max={27}
label="Number of teams" label={t`Number of teams`}
value={n} value={n}
onChange={(value) => setN(value as number)} onChange={(value) => setN(value as number)}
w={150} w={150}
@@ -1,7 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments"; import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
@@ -9,6 +9,8 @@ import TeamAssignmentPreview from "@/features/tournaments/components/team-assign
import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react"; import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { PlayerInfo } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
import { useQueryClient } from "@tanstack/react-query"; 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")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -23,12 +25,28 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
loader: ({ context }) => ({ loader: ({ context }) => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: `Manage ${context.tournament.name}`, title: msg`Manage {name}`,
titleValues: { name: context.tournament.name },
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: AssignPartnersPending,
}); });
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>
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack>
</Stack>
);
}
interface TeamAssignment { interface TeamAssignment {
player1: PlayerInfo; player1: PlayerInfo;
player2: PlayerInfo; player2: PlayerInfo;
@@ -105,19 +123,19 @@ function RouteComponent() {
{freeAgents.length} {freeAgents.length}
</Text> </Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{freeAgents.length === 1 ? "player enrolled" : "players enrolled"} <Plural value={freeAgents.length} one="player enrolled" other="players enrolled" />
</Text> </Text>
</Group> </Group>
{!hasEnoughPlayers && ( {!hasEnoughPlayers && (
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}> <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> </Alert>
)} )}
{hasOddPlayers && ( {hasOddPlayers && (
<Alert color="red" icon={<WarningCircleIcon size={16} />}> <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> </Alert>
)} )}
@@ -127,7 +145,7 @@ function RouteComponent() {
onClick={handleGenerate} onClick={handleGenerate}
loading={generateMutation.isPending} loading={generateMutation.isPending}
> >
Generate Random Pairings <Trans>Generate Random Pairings</Trans>
</Button> </Button>
)} )}
</Stack> </Stack>
@@ -136,7 +154,7 @@ function RouteComponent() {
<Stack gap="md"> <Stack gap="md">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Text size="lg" fw={600}> <Text size="lg" fw={600}>
Partner Assignments <Trans>Partner Assignments</Trans>
</Text> </Text>
<Group gap="sm"> <Group gap="sm">
<Button <Button
@@ -146,7 +164,7 @@ function RouteComponent() {
loading={generateMutation.isPending} loading={generateMutation.isPending}
size="sm" size="sm"
> >
Re-roll <Trans>Re-roll</Trans>
</Button> </Button>
<Button <Button
leftSection={<CheckCircleIcon size={18} />} leftSection={<CheckCircleIcon size={18} />}
@@ -154,7 +172,7 @@ function RouteComponent() {
loading={confirmMutation.isPending} loading={confirmMutation.isPending}
size="sm" size="sm"
> >
Confirm & Create Teams <Trans>Confirm & Create Teams</Trans>
</Button> </Button>
</Group> </Group>
</Group> </Group>
@@ -2,6 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -18,13 +20,32 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
loader: ({ context }) => ({ loader: ({ context }) => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: `Manage ${context.tournament.name}`, title: msg`Manage {name}`,
titleValues: { name: context.tournament.name },
}, },
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTournamentPending,
}); });
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>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
return <ManageTournament tournamentId={id} />; return <ManageTournament tournamentId={id} />;
@@ -2,6 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams"; import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -18,13 +20,43 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
loader: ({ context }) => ({ loader: ({ context }) => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: `${context.tournament.name} Teams`, title: msg`{name} Teams`,
titleValues: { name: context.tournament.name },
}, },
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTeamsPending,
}); });
function ManageTeamsPending() {
return (
<Stack gap="xs">
<Box px="md">
<Skeleton height={42} radius="sm" />
</Box>
<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>
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { tournament } = Route.useRouteContext(); const { tournament } = Route.useRouteContext();
@@ -1,17 +1,20 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments"; import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/")({ export const Route = createFileRoute("/_authed/admin/tournaments/")({
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.list()); prefetchServerQuery(queryClient, tournamentQueries.list());
}, },
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: "Manage Tournaments", title: msg`Manage Tournaments`,
}, },
refresh: tournamentQueries.list().queryKey, refresh: tournamentQueries.list().queryKey,
withPadding: false, withPadding: false,
@@ -19,6 +22,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent, component: RouteComponent,
}); });
function TournamentListSkeleton() {
return (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
return <ManageTournaments />; return (
<Suspense fallback={<TournamentListSkeleton />}>
<ManageTournaments />
</Suspense>
);
} }
@@ -7,13 +7,15 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title } from "@mantine/core"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view"; import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components"; import { SpotifyControlsBar } from "@/features/spotify/components";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -33,12 +35,51 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
showSpotifyPanel: true, showSpotifyPanel: true,
header: { header: {
withBackButton: true, withBackButton: true,
title: `Run ${context.tournament.name}`, title: msg`Run {name}`,
titleValues: { name: context.tournament.name },
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: RunTournamentPending,
}); });
function RunTournamentPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`run-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`run-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
@@ -59,6 +100,16 @@ function RouteComponent() {
) || false; ) || false;
}, [tournament.matches]); }, [tournament.matches]);
const nextUpMatchId = useMemo(() => {
const ready = (tournament.matches ?? [])
.filter(
(match) =>
match.status === "ready" && match.home && match.away && !match.bye
)
.sort((a, b) => a.order - b.order);
return ready[0]?.id;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => { const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) { if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] }; return { winners: [], losers: [] };
@@ -108,11 +159,12 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated} hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional} isRegional={tournament.regional}
groupConfig={tournament.group_config} groupConfig={tournament.group_config}
nextUpMatchId={nextUpMatchId}
/> />
<Divider /> <Divider />
<div> <div>
<Title order={3} ta="center" mb="md">Knockout Bracket</Title> <Title order={3} ta="center" mb="md"><Trans>Knockout Bracket</Trans></Title>
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} /> <BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
</div> </div>
</Stack> </Stack>
) : hasGroupStage ? ( ) : hasGroupStage ? (
@@ -124,9 +176,10 @@ function RouteComponent() {
hasKnockoutBracket={knockoutBracketPopulated} hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional} isRegional={tournament.regional}
groupConfig={tournament.group_config} 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 ? ( tournament.regional === true ? (
+2 -1
View File
@@ -5,6 +5,7 @@ import PlayerStatsTableSkeleton from '@/features/players/components/player-stats
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'; import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { msg } from '@lingui/core/macro';
export const Route = createFileRoute('/_authed/badges')({ export const Route = createFileRoute('/_authed/badges')({
component: Badges, component: Badges,
@@ -16,7 +17,7 @@ export const Route = createFileRoute('/_authed/badges')({
withPadding: false, withPadding: false,
fullWidth: true, fullWidth: true,
header: { header: {
title: 'All Badges', title: msg`All Badges`,
withBackButton: true, withBackButton: true,
}, },
refresh: [badgeQueries.allBadges().queryKey], refresh: [badgeQueries.allBadges().queryKey],
+10 -3
View File
@@ -2,13 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
import { Box, Title, Stack } from "@mantine/core"; import { Box, Title, Stack } from "@mantine/core";
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker"; import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
import AccentColorPicker from "@/features/settings/components/accent-color-picker"; import AccentColorPicker from "@/features/settings/components/accent-color-picker";
import LocalePicker from "@/features/settings/components/locale-picker";
import { NotificationsSection } from "@/features/settings/components/notifications-section";
import { SignOutIcon } from "@phosphor-icons/react"; import { SignOutIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link"; 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")({ export const Route = createFileRoute("/_authed/settings")({
loader: () => ({ loader: () => ({
header: { header: {
title: "Settings", title: msg`Settings`,
withBackButton: true, withBackButton: true,
}, },
withPadding: false, withPadding: false,
@@ -17,6 +21,7 @@ export const Route = createFileRoute("/_authed/settings")({
}); });
function RouteComponent() { function RouteComponent() {
const { t } = useLingui();
return ( return (
<> <>
<Box <Box
@@ -26,13 +31,15 @@ function RouteComponent() {
borderBottom: "1px solid var(--mantine-color-default-border)", borderBottom: "1px solid var(--mantine-color-default-border)",
}} }}
> >
<Title order={3}>Appearance</Title> <Title order={3}><Trans>Appearance</Trans></Title>
<Stack> <Stack>
<AccentColorPicker /> <AccentColorPicker />
<ColorSchemePicker /> <ColorSchemePicker />
<LocalePicker />
</Stack> </Stack>
</Box> </Box>
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} /> <NotificationsSection />
<ListLink label={t`Sign Out`} to="/logout" Icon={SignOutIcon} />
</> </>
); );
} }
+20 -10
View File
@@ -1,25 +1,26 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table"; import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense, useState, useDeferredValue } from "react"; import { Suspense, useState, useDeferredValue, useEffect } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton"; import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import LeagueHeadToHead from "@/features/players/components/league-head-to-head"; import LeagueHeadToHead from "@/features/players/components/league-head-to-head";
import { Box, Loader, Tabs, Button, Group, Container, Stack } from "@mantine/core"; 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";
export const Route = createFileRoute("/_authed/stats")({ export const Route = createFileRoute("/_authed/stats")({
component: Stats, component: Stats,
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; const queryClient = context.queryClient;
prefetchServerQuery(queryClient, playerQueries.allStats('all')); prefetchServerQuery(queryClient, playerQueries.allStats('all'));
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
}, },
loader: () => ({ loader: () => ({
withPadding: false, withPadding: false,
fullWidth: true, fullWidth: true,
header: { header: {
title: "Player Stats" title: msg`Player Stats`
}, },
refresh: [playerQueries.allStats().queryKey], refresh: [playerQueries.allStats().queryKey],
}), }),
@@ -29,12 +30,21 @@ function Stats() {
const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all'); const [viewType, setViewType] = useState<'all' | 'mainline' | 'regional'>('all');
const deferredViewType = useDeferredValue(viewType); const deferredViewType = useDeferredValue(viewType);
const isStale = viewType !== deferredViewType; const isStale = viewType !== deferredViewType;
const queryClient = useQueryClient();
useEffect(() => {
const timeout = window.setTimeout(() => {
prefetchServerQuery(queryClient, playerQueries.allStats('mainline'));
prefetchServerQuery(queryClient, playerQueries.allStats('regional'));
}, 1500);
return () => window.clearTimeout(timeout);
}, [queryClient]);
return ( return (
<Tabs defaultValue="stats"> <Tabs defaultValue="stats">
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab value="stats">Stats</Tabs.Tab> <Tabs.Tab value="stats"><Trans>Stats</Trans></Tabs.Tab>
<Tabs.Tab value="h2h">Head to Head</Tabs.Tab> <Tabs.Tab value="h2h"><Trans>Head to Head</Trans></Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value="stats"> <Tabs.Panel value="stats">
@@ -46,25 +56,25 @@ function Stats() {
size="compact-xs" size="compact-xs"
onClick={() => setViewType('all')} onClick={() => setViewType('all')}
> >
All <Trans>All</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'mainline' ? 'filled' : 'light'} variant={viewType === 'mainline' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('mainline')} onClick={() => setViewType('mainline')}
> >
Mainline <Trans>Mainline</Trans>
</Button> </Button>
<Button <Button
variant={viewType === 'regional' ? 'filled' : 'light'} variant={viewType === 'regional' ? 'filled' : 'light'}
size="compact-xs" size="compact-xs"
onClick={() => setViewType('regional')} onClick={() => setViewType('regional')}
> >
Regional <Trans>Regional</Trans>
</Button> </Button>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}> <Suspense fallback={<PlayerStatsTableSkeleton hideFilters />}>
<PlayerStatsTable viewType={deferredViewType} /> <PlayerStatsTable viewType={deferredViewType} />
</Suspense> </Suspense>
</Box> </Box>
@@ -7,8 +7,9 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core"; import { Container } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; 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 BracketView from "@/features/bracket/components/bracket-view";
import { BracketPending } from "@/features/bracket/components/bracket-pending";
export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -31,46 +32,17 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: BracketPending,
}); });
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
const bracket: BracketData = useMemo(() => { const bracket: BracketData = useMemo(
if (!tournament.matches || tournament.matches.length === 0) { () => groupMatchesIntoBracket(tournament.matches),
return { winners: [], losers: [] }; [tournament.matches]
} );
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
return ( return (
<Container size="md" px={0}> <Container size="md" px={0}>
@@ -5,7 +5,7 @@ import {
} from "@/features/tournaments/queries"; } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container } from "@mantine/core"; import { Box, Card, Container, Group, Skeleton, SimpleGrid, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -28,8 +28,46 @@ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: GroupsPending,
}); });
function GroupsPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`groups-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`groups-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
@@ -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>
);
}
+9 -2
View File
@@ -4,6 +4,7 @@ import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
import { Suspense } from 'react' import { Suspense } from 'react'
import TournamentCardList from '@/features/tournaments/components/tournament-card-list' import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
import { Skeleton, Stack } from '@mantine/core' import { Skeleton, Stack } from '@mantine/core'
import { msg } from '@lingui/core/macro'
export const Route = createFileRoute('/_authed/tournaments/')({ export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {
@@ -13,7 +14,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
loader: () => ({ loader: () => ({
header: { header: {
withBackButton: true, withBackButton: true,
title: 'Tournaments', title: msg`Tournaments`,
}, },
refresh: tournamentQueries.list().queryKey refresh: tournamentQueries.list().queryKey
}), }),
@@ -23,7 +24,13 @@ export const Route = createFileRoute('/_authed/tournaments/')({
function RouteComponent() { function RouteComponent() {
return <Suspense fallback={<Stack gap="md"> return <Suspense fallback={<Stack gap="md">
{Array(10).fill(null).map((_, index) => ( {Array(10).fill(null).map((_, index) => (
<Skeleton height="120px" w="100%" /> <Skeleton
key={`tournament-card-skeleton-${index}`}
height="120px"
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.08, 0.35) }}
/>
))} ))}
</Stack>}> </Stack>}>
<TournamentCardList /> <TournamentCardList />
+25 -46
View File
@@ -1,9 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
let activeConnections = 0; let activeConnections = 0;
const encoder = new TextEncoder();
export const Route = createFileRoute("/api/events/$")({ export const Route = createFileRoute("/api/events/$")({
server: { server: {
@@ -13,63 +14,47 @@ export const Route = createFileRoute("/api/events/$")({
activeConnections++; activeConnections++;
const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; const connectionId = `conn_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`); logger.info(`ServerEvents | New connection ${connectionId}. Active: ${activeConnections}`);
let cleanedUp = false;
let cleanup = () => {};
const stream = new ReadableStream({ const stream = new ReadableStream({
start(controller) { start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; const send = (payload: unknown) => {
controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => {
logger.info("ServerEvents | Event received", event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try { try {
if (!controller.desiredSize || controller.desiredSize <= 0) { controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
logger.warn("ServerEvents | Stream closed, skipping event");
return;
}
controller.enqueue(new TextEncoder().encode(message));
} catch (error) { } catch (error) {
logger.error("ServerEvents | Error sending SSE message", error); logger.error("ServerEvents | Error sending SSE message", error);
cleanup();
} }
}; };
serverEvents.on("test", handleEvent); const handleEvent = (event: ServerEvent) => send(event);
serverEvents.on("match", handleEvent); for (const type of EVENT_TYPES) {
serverEvents.on("reaction", handleEvent); serverEvents.on(type, handleEvent);
}
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
try { send({ type: "ping", timestamp: Date.now() });
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); }, 15000);
setTimeout(() => { cleanup = () => {
try { if (cleanedUp) return;
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`; cleanedUp = true;
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
} catch (e) {
logger.error("ServerEvents | Heartbeat error", e);
}
}, 1000);
const cleanup = () => {
activeConnections--; activeConnections--;
serverEvents.off("test", handleEvent); for (const type of EVENT_TYPES) {
serverEvents.off("match", handleEvent); serverEvents.off(type, handleEvent);
serverEvents.off("reaction", handleEvent); }
clearInterval(pingInterval); clearInterval(pingInterval);
logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`); logger.info(`ServerEvents | Connection ${connectionId} cleanup completed. Active: ${activeConnections}`);
}; };
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
return cleanup;
send({ type: "connected" });
},
cancel() {
cleanup();
}, },
}); });
@@ -77,13 +62,7 @@ export const Route = createFileRoute("/api/events/$")({
headers: { headers: {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-store, must-revalidate", "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-Accel-Buffering": "no",
"X-Proxy-Buffering": "no",
"Proxy-Buffering": "off",
"Transfer-Encoding": "chunked",
}, },
}); });
}, },
+52
View File
@@ -0,0 +1,52 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
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: [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);
}
},
},
},
});
+41
View File
@@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
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: [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);
}
},
},
},
});
+41
View File
@@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
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: [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);
}
},
},
},
});
+7 -3
View File
@@ -1,11 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { SpotifyWebApiClient } from "@/lib/spotify/client"; import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types"; import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
import { localizedFor } from "@/lib/i18n/server-messages";
export const Route = createFileRoute("/api/spotify/capture")({ export const Route = createFileRoute("/api/spotify/capture")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
// No session middleware on this route; localize with DEFAULT_LOCALE.
const i18n = localizedFor(null);
try { try {
const cookies = request.headers.get("Cookie") || ""; const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match( const accessTokenMatch = cookies.match(
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
if (!accessTokenMatch) { if (!accessTokenMatch) {
return new Response( return new Response(
JSON.stringify({ error: "No access token found" }), JSON.stringify({ error: i18n._(msg`No access token found`) }),
{ {
status: 401, status: 401,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -29,7 +33,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
if (!snapshot) { if (!snapshot) {
return new Response( return new Response(
JSON.stringify({ error: "No active playback to capture" }), JSON.stringify({ error: i18n._(msg`No active playback to capture`) }),
{ {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -47,7 +51,7 @@ export const Route = createFileRoute("/api/spotify/capture")({
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
? error.message ? error.message
: "Failed to capture playback state"; : i18n._(msg`Failed to capture playback state`);
return new Response(JSON.stringify({ error: errorMessage }), { return new Response(JSON.stringify({ error: errorMessage }), {
status: 500, status: 500,
+26 -14
View File
@@ -1,5 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { SpotifyWebApiClient } from "@/lib/spotify/client"; import { SpotifyWebApiClient } from "@/lib/spotify/client";
import { localizedFor } from "@/lib/i18n/server-messages";
function getAccessTokenFromCookies(request: Request): string | null { function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get("cookie"); const cookieHeader = request.headers.get("cookie");
@@ -16,11 +18,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
// No session middleware on this route; localize with DEFAULT_LOCALE.
const i18n = localizedFor(null);
try { try {
const accessToken = getAccessTokenFromCookies(request); const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) { if (!accessToken) {
return new Response( return new Response(
JSON.stringify({ error: "No access token found" }), JSON.stringify({ error: i18n._(msg`No access token found`) }),
{ {
status: 401, status: 401,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -41,7 +45,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
if (!trackId) { if (!trackId) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "trackId is required for playTrack action", error: i18n._(msg`trackId is required for playTrack action`),
}), }),
{ {
status: 400, status: 400,
@@ -63,7 +67,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
case "volume": case "volume":
if (typeof volumePercent !== "number") { if (typeof volumePercent !== "number") {
return new Response( return new Response(
JSON.stringify({ error: "volumePercent must be a number" }), JSON.stringify({ error: i18n._(msg`volumePercent must be a number`) }),
{ {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -76,7 +80,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
if (!deviceId) { if (!deviceId) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "deviceId is required for transfer action", error: i18n._(msg`deviceId is required for transfer action`),
}), }),
{ {
status: 400, status: 400,
@@ -87,10 +91,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
await spotifyClient.transferPlayback(deviceId); await spotifyClient.transferPlayback(deviceId);
break; break;
default: default:
return new Response(JSON.stringify({ error: "Invalid action" }), { return new Response(
status: 400, JSON.stringify({ error: i18n._(msg`Invalid action`) }),
headers: { "Content-Type": "application/json" }, {
}); status: 400,
headers: { "Content-Type": "application/json" },
}
);
} }
return new Response(JSON.stringify({ success: true }), { 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")) { if (error.message.includes("NO_ACTIVE_DEVICE")) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: error: i18n._(
"No active device found. Please select a device first.", msg`No active device found. Please select a device first.`
),
}), }),
{ {
status: 400, status: 400,
@@ -117,7 +125,9 @@ export const Route = createFileRoute("/api/spotify/playback")({
if (error.message.includes("PREMIUM_REQUIRED")) { if (error.message.includes("PREMIUM_REQUIRED")) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Spotify Premium is required for playback control.", error: i18n._(
msg`Spotify Premium is required for playback control.`
),
}), }),
{ {
status: 403, status: 403,
@@ -135,7 +145,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Playback control failed", error: i18n._(msg`Playback control failed`),
details: error instanceof Error ? error.message : "Unknown error", details: error instanceof Error ? error.message : "Unknown error",
}), }),
{ {
@@ -147,11 +157,13 @@ export const Route = createFileRoute("/api/spotify/playback")({
}, },
GET: async ({ request }: { request: Request }) => { GET: async ({ request }: { request: Request }) => {
// No session middleware on this route; localize with DEFAULT_LOCALE.
const i18n = localizedFor(null);
try { try {
const accessToken = getAccessTokenFromCookies(request); const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) { if (!accessToken) {
return new Response( return new Response(
JSON.stringify({ error: "No access token found" }), JSON.stringify({ error: i18n._(msg`No access token found`) }),
{ {
status: 401, status: 401,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -190,7 +202,7 @@ export const Route = createFileRoute("/api/spotify/playback")({
} catch (error) { } catch (error) {
console.error("Get playback data error:", error); console.error("Get playback data error:", error);
return new Response( return new Response(
JSON.stringify({ error: "Failed to get playback data" }), JSON.stringify({ error: i18n._(msg`Failed to get playback data`) }),
{ {
status: 500, status: 500,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
+8 -4
View File
@@ -1,11 +1,15 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { SpotifyWebApiClient } from "@/lib/spotify/client"; import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types"; import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
import { localizedFor } from "@/lib/i18n/server-messages";
export const Route = createFileRoute("/api/spotify/resume")({ export const Route = createFileRoute("/api/spotify/resume")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }: { request: Request }) => { POST: async ({ request }: { request: Request }) => {
// No session middleware on this route; localize with DEFAULT_LOCALE.
const i18n = localizedFor(null);
try { try {
const cookies = request.headers.get("Cookie") || ""; const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match( const accessTokenMatch = cookies.match(
@@ -14,7 +18,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
if (!accessTokenMatch) { if (!accessTokenMatch) {
return new Response( return new Response(
JSON.stringify({ error: "No access token found" }), JSON.stringify({ error: i18n._(msg`No access token found`) }),
{ {
status: 401, status: 401,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -30,7 +34,7 @@ export const Route = createFileRoute("/api/spotify/resume")({
if (!snapshot) { if (!snapshot) {
return new Response( return new Response(
JSON.stringify({ error: "No snapshot provided" }), JSON.stringify({ error: i18n._(msg`No snapshot provided`) }),
{ {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -47,14 +51,14 @@ export const Route = createFileRoute("/api/spotify/resume")({
} catch (error) { } catch (error) {
console.error("Spotify resume error:", 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 instanceof Error) {
if ( if (
error.message.includes("Premium") || error.message.includes("Premium") ||
error.message.includes("403") error.message.includes("403")
) { ) {
errorMessage = "Spotify premium required"; errorMessage = i18n._(msg`Spotify premium required`);
} else { } else {
errorMessage = error.message; errorMessage = error.message;
} }
+12 -7
View File
@@ -1,7 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
import { z } from "zod"; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
@@ -13,6 +15,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
middleware: [superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
@@ -27,7 +30,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
if (!validationResult.success) { if (!validationResult.success) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Invalid input", error: i18n._(msg`Invalid input`),
details: validationResult.error.issues, details: validationResult.error.issues,
}), }),
{ {
@@ -40,7 +43,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
if (!logoFile || logoFile.size === 0) { if (!logoFile || logoFile.size === 0) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Logo file is required", error: i18n._(msg`Logo file is required`),
}), }),
{ {
status: 400, status: 400,
@@ -58,7 +61,9 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
if (!allowedTypes.includes(logoFile.type)) { if (!allowedTypes.includes(logoFile.type)) {
return new Response( return new Response(
JSON.stringify({ 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, status: 400,
@@ -71,7 +76,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
if (logoFile.size > maxSize) { if (logoFile.size > maxSize) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "File too large. Maximum size is 10MB.", error: i18n._(msg`File too large. Maximum size is 10MB.`),
}), }),
{ {
status: 400, status: 400,
@@ -84,7 +89,7 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
if (!team) { if (!team) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Team not found", error: i18n._(msg`Team not found`),
}), }),
{ {
status: 404, status: 404,
@@ -132,8 +137,8 @@ export const Route = createFileRoute("/api/teams/upload-logo")({
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Failed to upload logo", error: i18n._(msg`Failed to upload logo`),
message: error.message || "Unknown error occurred", message: error.message || i18n._(msg`Unknown error occurred`),
}), }),
{ {
status: 500, status: 500,
+12 -7
View File
@@ -1,7 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
import { z } from "zod"; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
@@ -13,6 +15,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
middleware: [superTokensRequestMiddleware], middleware: [superTokensRequestMiddleware],
handlers: { handlers: {
POST: async ({ request, context }) => { POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
try { try {
const userId = context.userAuthId; const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
@@ -28,7 +31,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
if (!validationResult.success) { if (!validationResult.success) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Invalid input", error: i18n._(msg`Invalid input`),
details: validationResult.error.issues, details: validationResult.error.issues,
}), }),
{ {
@@ -41,7 +44,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
if (!logoFile || logoFile.size === 0) { if (!logoFile || logoFile.size === 0) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Logo file is required", error: i18n._(msg`Logo file is required`),
}), }),
{ {
status: 400, status: 400,
@@ -59,7 +62,9 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
if (!allowedTypes.includes(logoFile.type)) { if (!allowedTypes.includes(logoFile.type)) {
return new Response( return new Response(
JSON.stringify({ 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, status: 400,
@@ -72,7 +77,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
if (logoFile.size > maxSize) { if (logoFile.size > maxSize) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "File too large. Maximum size is 10MB.", error: i18n._(msg`File too large. Maximum size is 10MB.`),
}), }),
{ {
status: 400, status: 400,
@@ -85,7 +90,7 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
if (!tournament) { if (!tournament) {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Tournament not found", error: i18n._(msg`Tournament not found`),
}), }),
{ {
status: 404, status: 404,
@@ -130,8 +135,8 @@ export const Route = createFileRoute("/api/tournaments/upload-logo")({
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
error: "Failed to upload logo", error: i18n._(msg`Failed to upload logo`),
message: error.message || "Unknown error occurred", message: error.message || i18n._(msg`Unknown error occurred`),
}), }),
{ {
status: 500, status: 500,
+15 -13
View File
@@ -23,25 +23,27 @@ import { useEffect } from 'react'
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
import { logger } from '@/lib/logger' import { logger } from '@/lib/logger'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react' import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button' import Button from './button'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) { export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter() const router = useRouter()
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useLingui()
const isRoot = useMatch({ const isRoot = useMatch({
strict: false, strict: false,
select: (state) => state.id === rootRouteId, select: (state) => state.id === rootRouteId,
}) })
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false) 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' const errorStack = error?.stack || 'No stack trace available'
useEffect(() => { useEffect(() => {
logger.error('DefaultCatchBoundary | ', error) logger.error('DefaultCatchBoundary | ', error)
if (errorMessage.toLowerCase().includes('unauthenticated')) { if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error('You\'ve been logged out') toast.error(t`You've been logged out`)
router.history.push('/login') router.history.push('/login')
throw redirect({ to: '/login' }) throw redirect({ to: '/login' })
} }
@@ -53,23 +55,23 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<Center> <Center>
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<XCircleIcon size={64} color="var(--mantine-color-red-6)" /> <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"> <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> </Text>
<Group gap="sm" mt="md"> <Group gap="sm" mt="md">
<Button <Button
variant="light" variant="light"
onClick={() => window.history.back()} onClick={() => window.history.back()}
> >
Go Back <Trans>Go Back</Trans>
</Button> </Button>
<MantineButton <MantineButton
component={Link} component={Link}
to="/" to="/"
variant="filled" variant="filled"
> >
Home <Trans>Home</Trans>
</MantineButton> </MantineButton>
</Group> </Group>
</Stack> </Stack>
@@ -84,21 +86,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
<Stack align="center" gap="md" w="100%"> <Stack align="center" gap="md" w="100%">
<WarningIcon size={64} color="var(--mantine-color-red-6)" /> <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"> <Text c="dimmed" ta="center">
An error occurred while loading this page. <Trans>An error occurred while loading this page.</Trans>
</Text> </Text>
<Box w="100%" mt="md"> <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 <Button
variant="subtle" variant="subtle"
size="compact-sm" size="compact-sm"
onClick={toggleDetails} onClick={toggleDetails}
fullWidth fullWidth
> >
{detailsOpened ? 'Hide' : 'Show'} details {detailsOpened ? <Trans>Hide details</Trans> : <Trans>Show details</Trans>}
</Button> </Button>
<Collapse in={detailsOpened}> <Collapse in={detailsOpened}>
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}> <Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
@@ -112,7 +114,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
variant="light" variant="light"
onClick={() => router.invalidate()} onClick={() => router.invalidate()}
> >
Retry <Trans>Retry</Trans>
</Button> </Button>
{isRoot ? ( {isRoot ? (
<MantineButton <MantineButton
@@ -120,14 +122,14 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
to="/" to="/"
variant="filled" variant="filled"
> >
Home <Trans>Home</Trans>
</MantineButton> </MantineButton>
) : ( ) : (
<Button <Button
variant="filled" variant="filled"
onClick={() => window.history.back()} onClick={() => window.history.back()}
> >
Go Back <Trans>Go Back</Trans>
</Button> </Button>
)} )}
</Group> </Group>
+6 -3
View File
@@ -10,6 +10,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import { XIcon } from "@phosphor-icons/react"; import { XIcon } from "@phosphor-icons/react";
import { useLingui } from "@lingui/react/macro";
interface AvatarProps interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> { extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
@@ -30,6 +31,7 @@ const Avatar = ({
contain = false, contain = false,
...props ...props
}: AvatarProps) => { }: AvatarProps) => {
const { t } = useLingui();
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src); const hasImage = Boolean(props.src);
@@ -98,20 +100,21 @@ const Avatar = ({
> >
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<ActionIcon <ActionIcon
variant="filled" variant="white"
color="dark" color="dark"
size="lg" size="lg"
radius="xl" radius="xl"
aria-label={t`Close image preview`}
style={{ style={{
position: 'absolute', position: 'absolute',
top: -10, top: -10,
right: -10, right: -10,
zIndex: 1000, zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
}} }}
onClick={() => setIsFullscreenOpen(false)} onClick={() => setIsFullscreenOpen(false)}
> >
<XIcon size={18} color="white" /> <XIcon size={18} />
</ActionIcon> </ActionIcon>
<Image <Image
+36
View File
@@ -0,0 +1,36 @@
.time {
transition: color 300ms ease-out;
}
.seconds {
display: inline-block;
}
.tick {
animation: digit-tick 200ms ease-out;
}
.finalTick {
animation: final-tick 250ms ease-out;
}
@keyframes digit-tick {
from {
opacity: 0.2;
transform: translateY(-0.12em);
}
}
@keyframes final-tick {
from {
opacity: 0.4;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.tick,
.finalTick {
animation: none;
}
}
+30 -10
View File
@@ -1,6 +1,8 @@
import useNow from '@/hooks/use-now'; import useNow from '@/hooks/use-now';
import { Text, Group } from '@mantine/core'; import { Text, Group } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import classes from './countdown.module.css';
interface CountdownProps { interface CountdownProps {
date: Date; date: Date;
@@ -30,25 +32,43 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
}; };
} }
const pad = (num: number) => num.toString().padStart(2, '0');
export function Countdown({ date, label, color }: CountdownProps) { export function Countdown({ date, label, color }: CountdownProps) {
const { t } = useLingui();
const now = useNow(); const now = useNow();
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]); const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
const formatTime = () => { const totalSecondsLeft = Math.max(
const pad = (num: number) => num.toString().padStart(2, '0'); 0,
Math.floor((date.getTime() - now.getTime()) / 1000)
);
const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
if (timeLeft.days > 0) { const prefix =
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; timeLeft.days > 0
} else { ? `${t`${timeLeft.days}d`} ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; : `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
}
};
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
<Text size='sm' fw={600} c={color} ff="monospace"> <Text
{formatTime()} size='sm'
fw={600}
c={isFinalStretch ? 'red' : color}
ff="monospace"
className={classes.time}
>
{prefix}
<span
key={timeLeft.seconds}
className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick
}`}
>
{pad(timeLeft.seconds)}
</span>
</Text> </Text>
</Group> </Group>
); );
+3 -1
View File
@@ -2,6 +2,7 @@ import { DatePicker, TimeInput } from "@mantine/dates";
import { ActionIcon, Stack } from "@mantine/core"; import { ActionIcon, Stack } from "@mantine/core";
import { useRef } from "react"; import { useRef } from "react";
import { ClockIcon } from "@phosphor-icons/react"; import { ClockIcon } from "@phosphor-icons/react";
import { useLingui } from "@lingui/react/macro";
interface DateTimePickerProps { interface DateTimePickerProps {
value: Date | null; value: Date | null;
@@ -16,6 +17,7 @@ const DateTimePicker = ({
label, label,
...rest ...rest
}: DateTimePickerProps) => { }: DateTimePickerProps) => {
const { t } = useLingui();
const timeRef = useRef<HTMLInputElement>(null); const timeRef = useRef<HTMLInputElement>(null);
const currentDate = value ? new Date(value) : null; const currentDate = value ? new Date(value) : null;
@@ -73,7 +75,7 @@ const DateTimePicker = ({
/> />
<TimeInput <TimeInput
ref={timeRef} ref={timeRef}
label="Time" label={t`Time`}
size="md" size="md"
value={formatTime(currentDate)} value={formatTime(currentDate)}
onChange={handleTimeChange} onChange={handleTimeChange}
+32
View File
@@ -0,0 +1,32 @@
import { Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { ReactNode } from "react";
interface EmptyStateProps {
icon: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
const EmptyState = ({ icon, title, description, action }: EmptyStateProps) => {
return (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
{icon}
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} c="dimmed" ta="center">
{title}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center" maw={280}>
{description}
</Text>
)}
</Stack>
{action}
</Stack>
);
};
export default EmptyState;
+61 -83
View File
@@ -1,34 +1,29 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Paper, Box } from "@mantine/core"; import { Box, Avatar as MantineAvatar } from "@mantine/core";
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
} from "@mantine/core";
interface GlitchAvatarProps interface GlitchAvatarProps {
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string; name: string;
src?: string; src?: string;
glitchSrc?: string; glitchSrc?: string;
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean; withBorder?: boolean;
contain?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
px?: string | number;
} }
const FRAME_PADDING = 8;
const toCssRadius = (radius: string | number) =>
typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`;
const GlitchAvatar = ({ const GlitchAvatar = ({
name, name,
src, src,
glitchSrc, glitchSrc,
size = 35, size = 35,
radius = "100%", radius = "md",
withBorder = true, withBorder = true,
contain = false,
children, children,
px,
...props
}: GlitchAvatarProps) => { }: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false); const [showGlitch, setShowGlitch] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -37,13 +32,16 @@ const GlitchAvatar = ({
useEffect(() => { useEffect(() => {
if (!glitchSrc) return; if (!glitchSrc) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNextGlitch = () => { const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000; const delay = Math.random() * 10000 + 5000;
return setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(true); setShowGlitch(true);
setIsPlaying(true); setIsPlaying(true);
setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(false); setShowGlitch(false);
setIsPlaying(false); setIsPlaying(false);
scheduleNextGlitch(); scheduleNextGlitch();
@@ -51,7 +49,7 @@ const GlitchAvatar = ({
}, delay); }, delay);
}; };
const timeoutId = scheduleNextGlitch(); scheduleNextGlitch();
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [glitchSrc]); }, [glitchSrc]);
@@ -85,92 +83,72 @@ const GlitchAvatar = ({
}); });
}, [showGlitch, isPlaying]); }, [showGlitch, isPlaying]);
const innerRadius = toCssRadius(radius);
return ( return (
<Box <Box
style={{ style={{
padding: "8px",
borderRadius:
typeof radius === "number"
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative", position: "relative",
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 ? (
style={{ <Box style={{ position: "relative" }}>
opacity: showGlitch ? 0 : 1, <img
transition: "opacity 0.05s ease-in-out",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{
cursor: "default",
}}
>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
styles={{
image: {
objectFit: contain ? "contain" : "cover",
},
}}
src={src} src={src}
{...props} alt={name}
>
{children}
</MantineAvatar>
</Paper>
</Box>
{glitchSrc && (
<Box
style={{
position: "absolute",
top: "8px",
left: "8px",
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: "opacity 0.05s ease-in-out",
pointerEvents: "none",
}}
>
<Paper
py={size / 12.5}
px={size / 20}
bg="var(--mantine-color-default-border)"
radius={radius}
withBorder={false}
style={{ style={{
overflow: "hidden", 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",
}} }}
> />
{glitchSrc && (
<video <video
ref={videoRef} ref={videoRef}
src={glitchSrc} src={glitchSrc}
style={{ style={{
width: `${size}px`, position: "absolute",
height: `${size}px`, inset: 0,
objectFit: contain ? "contain" : "cover", width: "100%",
borderRadius: typeof radius === "number" ? `${radius}px` : radius, height: "100%",
display: "block", objectFit: "contain",
borderRadius: innerRadius,
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
pointerEvents: "none",
}} }}
muted muted
playsInline playsInline
preload="auto" preload="auto"
/> />
</Paper> )}
</Box> </Box>
) : (
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
>
{children}
</MantineAvatar>
)} )}
</Box> </Box>
); );
+73
View File
@@ -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;
+5 -3
View File
@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
export function IOSInstallPrompt() { export function IOSInstallPrompt() {
const { t } = useLingui()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
@@ -31,8 +33,8 @@ export function IOSInstallPrompt() {
if (!show || !platform) return null if (!show || !platform) return null
const instructions = platform === 'ios' const instructions = platform === 'ios'
? 'Tap Share → Add to Home Screen' ? t`Tap Share → Add to Home Screen`
: 'Tap Menu (⋮) → Add to Home screen' : t`Tap Menu (⋮) → Add to Home screen`
return ( return (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}> <Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
@@ -42,7 +44,7 @@ export function IOSInstallPrompt() {
<DownloadIcon size={20} style={{ flexShrink: 0 }} /> <DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}> <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>Please install FLXN This will save me Twilio credits as you won't be signed out!</Trans>
</Text> </Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}> <Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions} {instructions}
+2
View File
@@ -1,5 +1,6 @@
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core"; import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import styles from "./list-row.module.css";
interface ListButtonProps { interface ListButtonProps {
label: string; label: string;
@@ -12,6 +13,7 @@ const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return ( return (
<> <>
<UnstyledButton <UnstyledButton
className={styles.listRow}
w="100%" w="100%"
p="md" p="md"
component={"button"} component={"button"}
+3 -1
View File
@@ -1,6 +1,7 @@
import { Divider, NavLink, Text } from "@mantine/core"; import { Divider, NavLink, Text } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import styles from "./list-row.module.css";
interface ListLinkProps { interface ListLinkProps {
label: string; label: string;
@@ -15,6 +16,7 @@ const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
return ( return (
<> <>
<NavLink <NavLink
className={styles.listRow}
disabled={disabled} disabled={disabled}
w="100%" w="100%"
p="md" p="md"
+23
View File
@@ -0,0 +1,23 @@
.listRow {
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.listRow:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.listRow:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
@media (prefers-reduced-motion: no-preference) {
.listRow {
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.listRow:active:not(:disabled) {
transform: translateY(1px);
}
}

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