Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deabcedff7 | ||
|
|
2e7098e566 | ||
|
|
6e7ef894bf | ||
|
|
562d8294da | ||
|
|
ca5bafff46 | ||
|
|
12dcf00d5f | ||
|
|
70d591f925 | ||
|
|
fda8751642 | ||
|
|
bccadd18e2 | ||
|
|
41cfcc0260 | ||
|
|
71641f61bf | ||
|
|
1f1de2e04b | ||
|
|
9353fa8492 | ||
|
|
d2e1e5d4f0 | ||
|
|
957ff79033 | ||
|
|
2551ff8bb3 | ||
|
|
ef06665fbc | ||
|
|
76306cc937 | ||
|
|
42263c2e7b | ||
|
|
152235dd14 | ||
|
|
0665521a7c | ||
|
|
6fddbbab68 | ||
|
|
3909fbc966 |
@@ -79,9 +79,6 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: pin rancher host
|
|
||||||
run: echo "192.168.4.43 rancher.yohler.net" >> /etc/hosts
|
|
||||||
|
|
||||||
- name: Set environment variables
|
- name: Set environment variables
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ yarn.lock
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
/_scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
/dist/
|
/dist/
|
||||||
@@ -2,7 +2,7 @@ FROM oven/bun:1 AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lock ./
|
COPY package.json bun.lockb* ./
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ 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))"
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# FLXN Replay — Design Spec (v1, locked)
|
|
||||||
|
|
||||||
**Status:** design locked, not yet built. The tournament recap feature: a **player-owned recap** and an **overall tournament recap**, viewable **retroactively** from any completed tournament. Content is shaped to render into a **shareable card layout** (the owner's friend is producing the SVG/PNG graphic template — we spec the payload, not the artwork).
|
|
||||||
|
|
||||||
**No new collection.** Everything below is derivable at read time from existing data — the same philosophy as the podium/placement derivation in `src/lib/pocketbase/util/transform-types.ts` (`transformTournament` computes `isComplete`, `first/second/third_place` purely from ended matches). Replay is a pair of server fns that compute a recap payload from data we already store.
|
|
||||||
|
|
||||||
## Availability
|
|
||||||
- A tournament has a Replay when it's **complete**: every non-bye match `status === "ended"` — reuse the exact `isComplete` derivation from `transformTournament` (`src/lib/pocketbase/util/transform-types.ts:218-219`).
|
|
||||||
- **Overall recap is public** (any authed user). **Player recap** exists for every enrolled player; the "View Replay" entry shows participants their own recap.
|
|
||||||
- Retroactive by construction: old completed tournaments get Replays for free since nothing is written at tournament end.
|
|
||||||
|
|
||||||
## Data sources (all existing)
|
|
||||||
- **Matches** — `status: "ended"`, `home_cups`/`away_cups`, `ot_count`, `start_time`/`end_time`, `home_seed`/`away_seed`, `round`, `lid`, `order`, `is_losers_bracket`, `reset`, `bye` (`src/features/matches/types.ts`, transformed at `src/lib/pocketbase/util/transform-types.ts:30`). `pbAdmin.getTournament(id)` already returns matches + teams expanded, plus `team_stats` from the `team_stats_per_tournament` view (`src/lib/pocketbase/services/tournaments.ts:21`).
|
|
||||||
- **Stats views** — `team_stats_per_tournament` gives per-team W/L, cups for/against, margins for this event (mapped onto `tournament.team_stats` in `transformTournament`). Global `player_stats` / `player_mainline_stats` / `player_regional_stats` views exist (`pb_migrations/1783926000_optimized_player_stats_views.js`) but are all-time; per-event player numbers come from the tournament's own matches (teams are 2-player, so a player's event record == their team's record).
|
|
||||||
- **Reactions** — `reactions` collection `{match, player, emoji}` (`src/lib/pocketbase/services/reactions.ts`). Currently only fetched per match; add one service method fetching all reactions for a tournament via filter `match.tournament = "<id>"`.
|
|
||||||
- **Predictions** — `computePredictionScore` (points, correct, `predictedChampionId`) in `src/features/predictions/utils.ts` and `getPredictionsLeaderboard` in `src/features/predictions/server.ts` give points / rank / called-the-champ directly.
|
|
||||||
- **Badges** — `badge_progress` (`earned` boolean, no per-tournament attribution) — see open questions.
|
|
||||||
|
|
||||||
## Player recap (content + derivation)
|
|
||||||
All from the completed tournament's ended matches involving the player's team (find the team by scanning `tournament.teams[].players` for the player id):
|
|
||||||
- **W–L + win %** — team's rows from `tournament.team_stats`, or count from matches.
|
|
||||||
- **Cups sunk / conceded** — `total_cups_made` / `total_cups_against` from `team_stats`.
|
|
||||||
- **Biggest blowout margin** — max `|home_cups - away_cups|` across their wins, with opponent.
|
|
||||||
- **Longest win streak** — sort their matches by `order` (fallback `end_time`), scan for the longest run of wins.
|
|
||||||
- **Nemesis** — the team that ended their tournament (opponent in their final loss; in double-elim that's their losers-bracket exit), or if undefeated/champion, worst H2H by aggregate cup differential.
|
|
||||||
- **Victim** — opponent with the biggest aggregate cup differential in the player's favor across H2H games.
|
|
||||||
- **OT games survived** — their ended matches with `ot_count >= 1`; show count and OT record.
|
|
||||||
- **Final placement + podium** — top 3 from the existing podium derivation; below that, label by exit round ("Eliminated in Match 12" / round name via `getMatchLabel`-style logic) rather than a fake numeric rank.
|
|
||||||
- **Partner chemistry** — teammate (the other id on the roster) + combined record; identical to team record, framed as "You and X went 4–1 together".
|
|
||||||
- **Prediction result** — points / rank / champion-call from the predictions leaderboard; omit the block if they didn't submit.
|
|
||||||
- **Most-reacted match** — their match with the most reaction rows; include the top emoji.
|
|
||||||
- **Badges earned this event** — see open questions; v1 approach below.
|
|
||||||
|
|
||||||
## Overall recap (content + derivation)
|
|
||||||
- **Champion + podium** — existing `first/second/third_place` derivation.
|
|
||||||
- **Biggest upset** — among ended matches where both `home_seed` and `away_seed` are set, the win with the largest (winner seed − loser seed) gap.
|
|
||||||
- **Highest-scoring match** — max `home_cups + away_cups`. **Closest** — min nonzero margin (tie-break: more total cups). **Longest** — max `ot_count` (tie-break: `end_time - start_time` when both set).
|
|
||||||
- **Most cups in one game (single team)** — max of `home_cups`/`away_cups` with team + match.
|
|
||||||
- **Totals** — Σ cups, count of ended matches, count with `ot_count >= 1` (+ total OT periods).
|
|
||||||
- **MVP** — best `team_stats` row by win %, then margin (cup differential); surfaced as the two players of that team (or the champion team — tunable).
|
|
||||||
- **Most-reacted moment** — match with most reactions overall + top emoji.
|
|
||||||
- **Prediction-pool winner** — top of `getPredictionsLeaderboard`; omit if nobody predicted.
|
|
||||||
- **Team count / attendance** — `tournament.teams.length` / unique players across rosters.
|
|
||||||
|
|
||||||
## Share-card layout
|
|
||||||
- The recap payload is a list of **discrete stat blocks** (`{ key, label, value, sublabel?, team?/player?, matchRef? }`) so the external SVG/PNG template can consume the same JSON the app renders — don't bake copy into components.
|
|
||||||
- In-app, render as a vertical stack of card sections (hero: placement/champion; then blocks), constrained to a portrait share-card aspect so what you see is what the graphic shows.
|
|
||||||
- v1 sharing: the friend's template consumes the payload out-of-band; an in-app "Share" (Web Share API / rendered PNG) is explicitly **not** in v1 scope — just keep the payload shape stable.
|
|
||||||
|
|
||||||
## Entry points
|
|
||||||
- `TournamentStats` (`src/features/tournaments/components/tournament-stats.tsx`) already renders the completed-tournament overview with `ListLink` rows ("View Bracket", "View Predictions" ~lines 178-196). Add **"View Replay"** there, gated on the same `isComplete` memo already computed in that file. It links to the overall recap; inside, a participant sees a "Your Replay" hero button to their own player recap.
|
|
||||||
|
|
||||||
## Reuses / dependencies
|
|
||||||
- `pbAdmin.getTournament` (one fetch covers matches, teams, team_stats), podium derivation, predictions scoring + leaderboard, reactions collection, `ListLink`, `SwipeableTabs`/sheet patterns, `useServerSuspenseQuery`.
|
|
||||||
- No dependency on any Wave 2/3 feature; ships standalone.
|
|
||||||
|
|
||||||
## Implementation notes for a fresh agent (build this cold)
|
|
||||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/flxn-replay.md` (this file) fully first, then:
|
|
||||||
- **No migration.** Replay writes nothing. Do not add a collection.
|
|
||||||
- **New feature dir:** `src/features/replay/{types.ts, server.ts, queries.ts, utils.ts, components/}`. Keep every derivation (streaks, nemesis/victim, upset, superlatives) as pure functions over `Match[]`/`TournamentTeamStats[]` in `utils.ts`, unit-testable like `src/features/predictions/utils.ts`.
|
|
||||||
- **Server fns:** two — `getTournamentRecap(tournamentId)` and `getPlayerRecap({tournamentId, playerId})` — following `createServerFn().validator(zod).middleware([...]).handler(...)` + `toServerResult`, with `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])`; copy the shape of `getPredictionsLeaderboard` in `src/features/predictions/server.ts` (it already does getTournamentOrThrow + derive-from-matches). Resolve the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)`. Throw if the tournament isn't complete (reuse the `isComplete` logic; export it from a shared util rather than copy-pasting a third time — it already exists twice in `transform-types.ts`).
|
|
||||||
- **Reactions per tournament:** add `getReactionsForTournament(tournamentId)` to `src/lib/pocketbase/services/reactions.ts` — `getFullList({ filter: \`match.tournament="${id}"\` })` — and aggregate counts per match in `utils.ts`.
|
|
||||||
- **Prediction block:** call the existing prediction server logic (or its underlying service + `computePredictionScore`) inside the recap fn; don't re-implement scoring.
|
|
||||||
- **Badges block (v1):** fetch the player's `badge_progress` where `earned = true` and `updated` falls within `[tournament.start_time, tournament.end_time + 24h]` — attribution by timestamp. This is best-effort (see open questions); render nothing when empty.
|
|
||||||
- **Routes:** `src/app/routes/_authed/tournaments/$id.replay.tsx` (overall) and `src/app/routes/_authed/tournaments/$id.replay_.$playerId.tsx` (player), mirroring `$id.predictions.tsx` / `$id.predictions_.$playerId.tsx` exactly (same `createFileRoute` + loader header + Suspense pattern; note sub-routes use `$id`, only the profile route uses `$tournamentId`).
|
|
||||||
- **Client data:** `useServerSuspenseQuery` with a `replayKeys`/`replayQueries` module copying `src/features/predictions/queries.ts`. Data is immutable post-completion — no SSE wiring, no invalidation, long `staleTime` is fine.
|
|
||||||
- **Entry point:** in `tournament-stats.tsx`, add `{isComplete && <ListLink label="View Replay" to={\`/tournaments/${tournament.id}/replay\`} Icon={...} />}` next to the existing View Bracket/Predictions links. Skip it for `tournament.regional` (old regional data is flagged unreliable in that same file).
|
|
||||||
- **Design system:** Mantine 8 + app theme (`src/lib/mantine/mantine-provider.tsx`: `defaultRadius: "sm"`, primary = user accent, press feedback via the `flxn-press` activeClassName). The recap should feel like a highlight reel, not a table — but tasteful motion only (iOS-like easing, 120–350ms), accent color reserved for the hero stat per card, and match neighboring components rather than inventing a new look. Reuse `TeamAvatar`/`PlayerAvatar` in stat blocks.
|
|
||||||
- **Toasts:** none needed (read-only feature); errors surface via the route's error boundary.
|
|
||||||
|
|
||||||
## Tunables / open questions
|
|
||||||
- **Badge attribution** is the one shaky derivation: `badge_progress.earned` is a boolean with no event linkage, and retroactive/recomputed badges (`migrateBadgeProgress`) break timestamp inference. v1 = timestamp window (above); the clean fix is re-running badge criteria with/without this tournament's matches and diffing — decide if that's worth it before building.
|
|
||||||
- **Biggest upset without seeds:** group-stage matches (`round === -1`) and some formats lack `home_seed`/`away_seed`; v1 skips seedless matches. Fall back to group placement later?
|
|
||||||
- **Player recap visibility:** owner-only vs. public like `$id.predictions_.$playerId.tsx`? Default: viewable by any participant (it's all derivable from public match data anyway), but the entry point only advertises your own.
|
|
||||||
- **"OT games survived"** — count OT games played, or only OT wins? Default: played, with the win/loss split shown.
|
|
||||||
- **MVP definition** — best win % then margin (as specced) vs. simply the champions. Revisit after seeing real output.
|
|
||||||
- **Regional tournaments** — excluded in v1 (data quality); could be enabled per-tournament later.
|
|
||||||
- **Non-podium placement copy** — exit-round label vs. numeric rank; locked to label for v1.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Free-Agent Matchmaking Board — Design Spec (v1, locked)
|
|
||||||
|
|
||||||
**Status:** design locked, not yet built. Extends the existing free-agent enrollment flow; the admin random-pairing flow stays as the fallback for anyone unmatched.
|
|
||||||
|
|
||||||
Players who sign up without a team ("free agents") can see each other — with stats and badges — and **claim a partner**. The claimed player must **confirm** (two-sided handshake, mirroring the score-report Confirm pattern). On mutual accept the app creates the team for them. The admin still arbitrates leftovers.
|
|
||||||
|
|
||||||
## What exists today (baseline)
|
|
||||||
- `free_agents` collection (`pbc_2929550049`): `{ player → players, tournament → tournaments, phone }`. Created in `pb_migrations/1758388728_created_free_agents.js`, tournament relation added in `1758402128_updated_free_agents.js`. No partner/status field, no claim concept.
|
|
||||||
- Service: `enrollFreeAgent` / `unenrollFreeAgent` / `getFreeAgents` in `src/lib/pocketbase/services/tournaments.ts` (~L189–216); `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` returns `{ id, phone, player }`.
|
|
||||||
- Server fns in `src/features/tournaments/server.ts`: `getFreeAgents` (L109, any authed user — note it currently returns phones for everyone), `enrollFreeAgent` (L116), `unenrollFreeAgent` (L131). All emit `{ type: "tournament", tournamentId }`.
|
|
||||||
- UI: `src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx` (enroll sheet) and `enrolled-free-agent.tsx` (the pool view). **Regional agents can't see the pool at all** — the `isRegional` branch shows only your own card with "Partners will be randomly assigned". Non-regional agents see a plain name + phone list. No stats, no badges, no actions.
|
|
||||||
- Admin fallback: `src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx` → `useGenerateRandomTeams` / `useConfirmTeamAssignments` → `generateRandomTeams` (L146) and `confirmTeamAssignments` (L326) in `src/features/tournaments/server.ts`. Confirm finds-or-creates a `private: true` team per pair, `enrollTeam`s it, then `unenrollFreeAgent`s both players.
|
|
||||||
- The two-sided confirm precedent: `reportMatchScore` / `confirmMatchScore` in `src/features/matches/server.ts` (~L828–928). Pending state lives **on the record itself** (`reported_by_team`, `reported_home_cups`, …), the other side confirms, and a cross-report with identical values auto-finalizes.
|
|
||||||
|
|
||||||
## The board
|
|
||||||
Replaces the plain list inside `enrolled-free-agent.tsx` for **both** regional and mainline tournaments (the regional visibility gate is relaxed — see below). Each agent renders as a card:
|
|
||||||
- `PlayerAvatar` + name.
|
|
||||||
- **Stats strip:** matches, win %, avg cups/match (from `PlayerStats` — `src/features/players/types.ts` L34).
|
|
||||||
- **Badges row:** the player's earned badges, compact (icon row, overflow "+N").
|
|
||||||
- **Action:** one of `Claim` / `Claimed — waiting` (yours, with Withdraw) / `Accept · Decline` (they claimed you) / a subtle "pending with someone else" state.
|
|
||||||
- Phone: keep today's behavior for mainline (copy-to-clipboard affordance); regionals stay phone-less (`enrollFreeAgent` is already called with `""` phone via the regional path, and admin-enroll at L737 passes `""`).
|
|
||||||
|
|
||||||
Fetch stats/badges in bulk — `getAllPlayerStats` (`src/features/players/server.ts` L174) and `getAllEarnedBadges` (`src/features/badges/server.ts`, keyed in `src/features/badges/queries.ts`) — filter client-side to the agents on the board. Do **not** issue per-agent `getPlayerStats`/`getPlayerBadges` calls (N+1).
|
|
||||||
|
|
||||||
## The handshake
|
|
||||||
1. **Claim:** agent A taps Claim on agent B. A's `free_agents` row gets `claimed = <B's row id>`. A can have **one outgoing claim** at a time (withdraw to switch).
|
|
||||||
2. **B sees it** (SSE-refreshed): "A wants to partner with you" with Accept / Decline. B can hold multiple *incoming* claims; accepting one implicitly declines the rest.
|
|
||||||
3. **Accept →** server verifies A's claim still points at B, then creates the team (reusing the `confirmTeamAssignments` find-or-create logic), enrolls it in the tournament, and `unenrollFreeAgent`s both — both rows vanish from the pool, exactly like the admin flow.
|
|
||||||
4. **Decline →** clears A's `claimed` field. A is free to claim someone else. (Optionally notify A — see tunables.)
|
|
||||||
5. **Symmetry shortcut:** if B already has an outgoing claim on A when A claims B (or vice versa), that *is* mutual intent — auto-accept immediately, mirroring how `reportMatchScore` auto-finalizes on an identical cross-report.
|
|
||||||
|
|
||||||
Team naming: default `"<A first> & <B first>"`, same shape the random generator produces; rename later via the existing team-edit flow.
|
|
||||||
|
|
||||||
## Data model (decided: Option A — field on `free_agents`)
|
|
||||||
Two options were weighed:
|
|
||||||
|
|
||||||
- **Option A (chosen): a self-relation on `free_agents`.** Add one nullable field `claimed` (relation → `free_agents`, maxSelect 1) — "this agent's outgoing claim". Everything derives from it: my outgoing claim = my row's `claimed`; my incoming claims = rows where `claimed = <my row id>`; decline = clear the field; match = both rows deleted. No status enum needed for v1 (one outgoing claim, terminal states delete rows). This mirrors the score-report precedent where pending state lives on the `matches` record, and unenroll/delete automatically cleans up outgoing claims (incoming pointers to a deleted row are cleared server-side on unenroll — one filtered update).
|
|
||||||
- **Option B (rejected for v1): a `partner_claims` collection** `{ tournament, from_agent, to_agent, status: pending|accepted|declined, created }`. Buys an audit trail, decline history (anti-re-spam), and native multi-claim — at the cost of a new collection, cross-record consistency, and cleanup hooks on unenroll. Revisit if tunables land on "multiple outgoing claims" or "declines block re-claiming".
|
|
||||||
|
|
||||||
`getFreeAgents` grows to expand `claimed` and return `{ id, phone, player, claimedAgentId }`; the board derives the four card states from that plus "who points at me".
|
|
||||||
|
|
||||||
## Server fns (new, in `src/features/tournaments/server.ts`)
|
|
||||||
All player-facing: `createServerFn().validator(zod).middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]).handler(...)` + `toServerResult`, resolving the caller via `pbAdmin.getPlayerByAuthId(context.userAuthId)` — copy `enrollFreeAgent` (L116) as the shell and the guard style of `confirmMatchScore`.
|
|
||||||
|
|
||||||
- `proposePartner({ tournamentId, targetAgentId })` — caller must be an enrolled agent with no outgoing claim; target must be a different agent in the same tournament. Sets `claimed`. If target already claims caller → run the accept path (auto-match). Emits `tournament`; push to target.
|
|
||||||
- `withdrawPartnerClaim({ tournamentId })` — clears caller's `claimed`. Emits `tournament`.
|
|
||||||
- `respondToPartnerClaim({ tournamentId, fromAgentId, accept: boolean })` — verifies `fromAgent.claimed === callerAgent.id` (claim may have been withdrawn — fail with a friendly error). Decline: clear it. Accept: **reuse the team path** — extract the find-or-create-private-team + `enrollTeam` block from `confirmTeamAssignments` (L341–381) into a shared helper (e.g. `createOrReuseTeamForPair(player1Id, player2Id, teamName, tournamentId)`), call it, then `unenrollFreeAgent` both (which drops both rows and any pointers). Emit `team` + `tournament`; push to the claimer.
|
|
||||||
|
|
||||||
Race safety: re-read both rows inside the handler before mutating (same optimistic pattern as score confirm — no locks, just verify-then-act; PB writes are serialized enough at this app's scale, per the existing flows).
|
|
||||||
|
|
||||||
## Realtime + push
|
|
||||||
- **SSE:** reuse the existing `tournament` event (`src/lib/events/emitter.ts` L25). The handler in `src/hooks/use-server-events.ts` (L51) already invalidates the `['tournaments']` prefix, which covers `tournamentKeys.free_agents(id)` (`['tournaments','free_agents',id]`, `src/features/tournaments/queries.ts` L9) — **no new event type needed**; the board updates live as claims land.
|
|
||||||
- **Push:** `sendPushToPlayer` in `src/lib/push/index.ts` — its doc comment already reserves this exact seam ("a free-agent claim involving the player"). Payloads (`PushPayload` in `src/lib/push/types.ts`):
|
|
||||||
- On claim → target: `{ title: "You've been claimed!", body: "<Name> wants to be your partner for <tournament>", url: "/", tag: "partner-claim-<agentId>" }`.
|
|
||||||
- On accept → claimer: `{ title: "It's a match", body: "<Name> accepted — you're teammates", ... }`.
|
|
||||||
- Decline: **no push** (avoid rejection stings; the cleared state shows on the board).
|
|
||||||
|
|
||||||
## Regional visibility gate (relaxed)
|
|
||||||
Delete the regional "you're enrolled, wait for assignment" dead-end in `enrolled-free-agent.tsx` (L45–74): regionals get the same board (stats, badges, claim) — that self-card + "Partners will be randomly assigned when enrollment closes" collapses into the board's header copy ("Claim a partner, or the admin pairs whoever's left"). Keep the regional enroll sheet copy in `enroll-free-agent.tsx` but update it to mention claiming. Phone stays hidden for regionals (the claim button replaces its purpose).
|
|
||||||
|
|
||||||
## Admin fallback interplay
|
|
||||||
- Accepted handshakes remove both agents from `free_agents`, so `assign-partners.tsx` and `generateRandomTeams` see only the leftovers — **zero changes to the pairing math**. The even-count and ≥2 guards (L156–162) still apply to what remains.
|
|
||||||
- Pending (unaccepted) claims are invisible to the random pairer — it may split a pending pair. Acceptable for v1: random pairing runs at deadline, and `confirmTeamAssignments`'s `unenrollFreeAgent` sweep clears all claim state. Optional nicety (cheap, do it): `generateRandomTeams` seeds mutually-irrelevant pairs randomly but **keeps any pending claim pair together** when both are still in the pool — a one-pass pre-grouping before the shuffle.
|
|
||||||
- Admin's enrolled-players count and `assign-partners` header need no changes; they read the same `useFreeAgents` query.
|
|
||||||
|
|
||||||
## Design system
|
|
||||||
- Mantine 8 + the app theme (`src/lib/mantine/mantine-provider.tsx`): `defaultRadius: "sm"`, dynamic `primaryColor`. Use `Card withBorder radius="md" p="md"` like the existing regional self-card so the board matches its neighbors.
|
|
||||||
- Accent discipline: **one** accented element per card — the `Claim` button. Waiting/pending states are `variant="subtle"`/`c="dimmed"`; Accept is filled, Decline is `variant="subtle" color="red"` (matching the enroll sheet's Cancel).
|
|
||||||
- Press feedback via the `flxn-press` theme; motion only where it earns it — a single 120–350ms iOS-eased transition when a card flips state (claimed → matched), no confetti, no bouncing.
|
|
||||||
- Badges row reuses the visual language of `src/features/badges/components/badge-showcase.tsx` at small size — don't invent a new badge chip.
|
|
||||||
|
|
||||||
## Implementation notes for a fresh agent (build this cold)
|
|
||||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/free-agents.md` (this file) fully first, then:
|
|
||||||
- **Migration:** one new `pb_migrations/*_updated_free_agents.js` adding the `claimed` self-relation (collectionId `pbc_2929550049`, maxSelect 1, required false, cascadeDelete false). Mimic `1758402128_updated_free_agents.js` (plain `migrate((app)=>{...})` with up + down). Players collection id is `pbc_3072146508`, tournaments `pbc_340646327`, teams `pbc_1568971955`. Collections have null rules; all access is server-side via `pbAdmin`.
|
|
||||||
- **Service layer:** extend `createTournamentsService` in `src/lib/pocketbase/services/tournaments.ts` — `setFreeAgentClaim(agentId, targetAgentId|null)`, `getFreeAgentByPlayer(playerId, tournamentId)` (the lookup already inlined in `unenrollFreeAgent` L198), and make `unenrollFreeAgent` also clear inbound pointers (`getFullList({ filter: 'claimed = "<rowId>"' })` → clear each). Update `transformFreeAgent` in `src/lib/pocketbase/util/transform-types.ts` to surface `claimedAgentId`.
|
|
||||||
- **Server fns:** the three fns above in `src/features/tournaments/server.ts`, next to `enrollFreeAgent`. Extract `createOrReuseTeamForPair` from `confirmTeamAssignments` (L341–381) as a module-level helper and call it from both places — do not fork the team-creation logic. Emit `emitServerEvent({ type: "tournament", tournamentId })` (and `{ type: "team" }` on match) exactly like the existing fns; fire `sendPushToPlayer` (`src/lib/push/index.ts`) after the write, non-blocking (`.catch(log)`), never failing the request.
|
|
||||||
- **Client data:** hooks in `src/features/tournaments/hooks/` following `use-enroll-free-agent.ts` (uses `useServerMutation` from `src/lib/tanstack-query/hooks`, invalidates `tournamentKeys.free_agents(id)`). Board reads `useFreeAgents` + `getAllPlayerStats` / `getAllEarnedBadges` via new bulk queries keyed like `src/features/players/queries.ts` / `src/features/badges/queries.ts`. No new SSE event type: `use-server-events.ts`'s `tournament` handler already invalidates the `['tournaments']` prefix.
|
|
||||||
- **UI:** rework `enrolled-free-agent.tsx` into a `FreeAgentBoard` (new file under the same dir; keep `unenroll-free-agent.tsx` at the bottom). Card = `PlayerAvatar` + name + stats strip + badge row + action slot. Confirm-style incoming-claim UI: filled Accept, subtle red Decline, same layout rhythm as the score-confirm affordance in the match card. Sheets via `@/components/sheet/sheet` + `useSheet`, buttons via `@/components/button`.
|
|
||||||
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only. "Claim sent", "You're teammates!", and a friendly error when a claim was withdrawn/taken ("They just partnered up — pick someone else").
|
|
||||||
- **Copy updates:** `enroll-free-agent.tsx` sheet text (both branches) now mentions the board + claiming; delete the regional-only self-card branch in the pool view.
|
|
||||||
- **Design system:** Mantine 8 + the app theme; `defaultRadius: "sm"`; press feedback via `flxn-press`; one accent per card; match neighboring components, don't invent a new look.
|
|
||||||
- **Verify:** enroll two players as free agents (one regional tournament, one mainline), claim, accept — confirm a `private: true` team appears on the tournament, both agents leave the pool, and `assign-partners` shows only leftovers. Then run the admin random flow on an even leftover pool to prove the fallback is untouched.
|
|
||||||
|
|
||||||
## Tunables / open questions
|
|
||||||
- **Multiple outgoing claims?** v1: one at a time (keeps Option A trivially consistent). If demand appears, move to Option B's collection.
|
|
||||||
- **Claim expiry?** v1: none — claims live until withdrawn, declined, or the pool is swept. Candidate: auto-expire via `updated` age (e.g. 24h) checked lazily in `getFreeAgents`.
|
|
||||||
- **Does admin auto-pair leftovers at a deadline?** Today it's manual (admin opens assign-partners and generates). Keep manual for v1; a scheduled auto-pair is a separate feature.
|
|
||||||
- **Decline memory:** should a declined claimer be blocked from re-claiming the same person? v1: no (rows carry no history). Option B enables it.
|
|
||||||
- **Keep pending pairs together in random pairing?** Recommended-cheap, but genuinely optional — cut it if it complicates the shuffle.
|
|
||||||
- **Phone visibility:** mainline keeps phones on the board for now; consider hiding until matched once claiming proves out.
|
|
||||||
- **Push copy/tag collapsing:** `tag: "partner-claim-<agentId>"` collapses repeat claims from the same person; revisit if it feels spammy.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Push Notifications — Ideas Board (nothing decided)
|
|
||||||
|
|
||||||
**Status:** brainstorm, NOT a spec. The push *foundation* is built; **zero product triggers are wired**. This doc is a menu of candidate notification types to pick from later — none of them are commitments, and the list is meant to be argued with, pruned, and added to.
|
|
||||||
|
|
||||||
## Guiding principle (the one non-negotiable)
|
|
||||||
|
|
||||||
**Minimal by default. Strictly opt-in. Do not annoy.**
|
|
||||||
|
|
||||||
- Every notification type ships **OFF** unless the user opts in — and even opted-in volume stays low.
|
|
||||||
- Push is a scarce resource: each notification spends trust. One annoying ping and people nuke permission at the OS level and we never get it back.
|
|
||||||
- Only a **tiny high-value default-on set** for users who flip the master toggle. Current candidates: **"you're up next"** and **"you were claimed as a partner"** — both rare, both personally about *you*, both actionable right now.
|
|
||||||
- Prefer notifications that are: about the recipient personally > rare > time-sensitive > tappable to something useful. Anything failing two of those four probably shouldn't exist.
|
|
||||||
|
|
||||||
## What already exists (the foundation)
|
|
||||||
|
|
||||||
- **`src/lib/push/index.ts`** — `sendPushToPlayer(playerId, { title, body, url?, icon?, tag? })`. Sends to every device the player subscribed, auto-prunes dead endpoints (404/410). The file itself documents the trigger seams as **deliberately unwired**.
|
|
||||||
- **`push_subscriptions` collection** — `pb_migrations/1784200000_created_push_subscriptions.js`, service helpers in `src/lib/pocketbase/services/push.ts`.
|
|
||||||
- **Opt-in settings toggle** — `src/features/settings/components/notifications-section.tsx` (per-device enable + "send test notification", via `src/hooks/use-web-push.ts`). Today it's one master switch; per-type preferences are a follow-up idea (see bottom).
|
|
||||||
- **SSE domain events** — `src/lib/events/emitter.ts` already types `match | reaction | tournament | team | player | badge` events, emitted from real product code paths (`src/features/matches/server.ts`, `src/features/tournaments/server.ts`, `src/features/badges/server.ts`, `src/features/players/server.ts`, `src/features/teams/server.ts`). **Most push triggers can hang off the exact same emit sites** — the moment we `emitServerEvent(...)`, we already know something notification-worthy happened.
|
|
||||||
|
|
||||||
Again: **no trigger below is wired.** The point of this board is to choose which few deserve to be.
|
|
||||||
|
|
||||||
## The menu
|
|
||||||
|
|
||||||
Columns: what fires it, who gets it, why it drives engagement, roughly how often it would fire, an annoyance-risk rating, and a suggested default (all of these are suggestions, not decisions). "Default ON" here means *on once the user enables notifications at all* — nothing fires for users who never flip the master switch.
|
|
||||||
|
|
||||||
### BEFORE the tournament
|
|
||||||
|
|
||||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| **New tournament posted / enrollment open** | Tournament created or opened for enrollment (`src/features/tournaments/server.ts` create emit, ~line 27) | All players | The starting gun — nobody wants to hear about a tournament secondhand | Rare (per tournament) | Low | Off (opt-in "announcements") |
|
|
||||||
| **Enrollment closing soon** | Timed job N hours before enrollment lock | Enrolled-nowhere players | FOMO nudge for fence-sitters | Once per tournament | **Med** — it's marketing, not information | Off |
|
|
||||||
| **You were claimed as a partner** | Free-agent partner assignment (`assignPartners` path in `src/features/tournaments/server.ts`, emits `team` + `tournament`, ~lines 385–397) | The claimed free agent(s) | Deeply personal — "someone picked YOU." Answers the question free agents are actively anxious about | Once per tournament per free agent | Low | **ON (candidate default)** |
|
|
||||||
| **Predictions are open** | Bracket generated / predictions window opens (`submitPrediction` lives in `src/features/predictions/server.ts`) | Enrolled players | Predictions only work if people fill them out before lock | Once per tournament | Low-med | Off |
|
|
||||||
| **Predictions closing soon** | Timed job before first-match lock (predictions lock on first match start) | Enrolled players who haven't submitted | Targeted (only non-submitters), genuinely useful deadline | ≤1 per tournament | Low-med | Off |
|
|
||||||
| **Side bets are open on a match** | Match hits `ready` (book opens per `docs/side-bets.md`) | Players with tokens, not in that match | Side-bets spec explicitly wants this nudge for marquee matches | Could be *every match* — needs throttling (e.g. finals only) | **High** if per-match; low if finals-only | Off |
|
|
||||||
| **Tournament starts in 1h** | Timed job vs. tournament start time | Enrolled players | Logistics — get people in the building | Once per tournament | Low | Off (but a strong opt-in) |
|
|
||||||
|
|
||||||
### DURING the tournament
|
|
||||||
|
|
||||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| **You're up next / your match started** | Admin starts the match — `startMatch` in `src/features/matches/server.ts` (~line 142, emits `match`) | The 2–4 players in the match | The single most valuable push in the app: personal, urgent, actionable ("get to the table") | 3–6 per player per tournament | Low — this is the notification people *install the PWA for* | **ON (candidate default)** |
|
|
||||||
| **Score reported on your match — confirm needed** | `reportMatchScore` (~line 828) when the *other* team reports | The team that must confirm | Unblocks the bracket; the confirmer often doesn't know they're the bottleneck | ≤1 per match played | Low | Off, but arguably deserves default-on — it's a to-do, not a broadcast |
|
|
||||||
| **Your match result is final** | `finalizeMatch` (~line 753, single finalization path for admin `endMatch` + player `confirmMatchScore`) | Players in the match | Closure + "what's next" tap-through | 1 per match played | Low-med (you usually *know* — you were there) | Off |
|
|
||||||
| **You advanced / you were eliminated** | Bracket propagation after finalize (same `finalizeMatch` seam) | The advancing/eliminated team | "You're in the semis" is a brag-worthy moment; elimination push is a kindness debate | ≤1 per round | Med — elimination pushes can feel like salt | Off; if built, maybe advance-only |
|
|
||||||
| **Someone reacted to your match** | `toggleMatchReaction` (~line 964, emits `reaction`) | Players in the match | Social warm-fuzzies | Potentially **very** chatty | **High** — classic notification spam shape; needs heavy batching ("5 reactions on your match") if ever built | Off, probably forever |
|
|
||||||
| **Big upset just happened** | `finalizeMatch` + an upset heuristic (seed gap / prediction consensus) | Everyone at the tournament | Shared "did you SEE that" moment; drives people to the bracket | 0–2 per tournament if threshold is strict | Med — must be genuinely rare to land | Off |
|
|
||||||
| **Side-bet settled (you won/lost)** | Settlement inside `finalizeMatch` per `docs/side-bets.md` | Bettors on that match | Win reveals are dopamine; losses close the loop | Per bet placed (self-inflicted volume) | Low-med — user opted in by betting | Off (or implicit-on for people who bet?) |
|
|
||||||
|
|
||||||
### AFTER the tournament
|
|
||||||
|
|
||||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| **Final results / you placed** | Tournament completed (tournament status emit in `src/features/tournaments/server.ts`) | All enrolled; podium gets a personal variant ("You took 2nd") | The recap moment; personal placement > generic results | Once per tournament | Low | Off (single post-tournament push is defensible as default though) |
|
|
||||||
| **You earned a badge** | `src/features/badges/server.ts` award (~line 44, emits `badge` with `playerId`) | The recipient | Personal, rare, purely positive — near-ideal push shape | Rare | Low | Off, but a strong candidate to promote later |
|
|
||||||
| **Prediction results — how you scored** | Final match finalizes → predictions settle | Players who submitted a prediction | Only reaches opt-ins-by-behavior; leaderboard tap-through | Once per tournament | Low | Off |
|
|
||||||
| **Side-bets final standings** | Tournament completes → side-bets leaderboard freezes | Players who placed ≥1 bet | Same self-selected audience as above | Once per tournament | Low | Off |
|
|
||||||
| **Your FLXN Replay is ready** | *(Future feature — no code yet.)* Post-tournament recap generation | All enrolled | Personalized recap = the highest-retention artifact we could push | Once per tournament | Low | Off until the feature exists; then a strong default candidate |
|
|
||||||
|
|
||||||
### AMBIENT / cross-tournament
|
|
||||||
|
|
||||||
| Candidate | Trigger | Audience | Why it hooks | Frequency | Annoyance | Default |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| **A rival is in your next match** | *(Future feature — rivalries don't exist in code yet; head-to-head data does: `getMatchesBetweenPlayers` / `getMatchesBetweenTeams` in `src/features/matches/server.ts`)* | Both sides of the rivalry | "Revenge match" framing is the best narrative hook in the app | Rare | Low-med | Off until rivalries exist |
|
|
||||||
| **New tournament posted** | Tournament create emit (also listed under BEFORE — could be the same "announcements" type) | Everyone | Re-engages lapsed players between tournaments | Rare | Low | Off (opt-in "announcements") |
|
|
||||||
| **Free agents need partners** | N unclaimed free agents as enrollment deadline nears | Enrolled players without full teams | Matchmaking pressure in both directions | ≤1 per tournament | Med | Off |
|
|
||||||
| **You've been made admin / roster changes touch you** | `player` emit sites in `src/features/players/server.ts` | Affected player | Administrative courtesy | Very rare | Low | Off — probably not worth building |
|
|
||||||
|
|
||||||
## Suggested tiny default-on set (opinion, not decision)
|
|
||||||
|
|
||||||
If a user enables notifications at all, they get exactly two things until they say otherwise:
|
|
||||||
|
|
||||||
1. **You're up next** — `startMatch` seam.
|
|
||||||
2. **You were claimed as a partner** — free-agent assignment seam.
|
|
||||||
|
|
||||||
Everything else is opt-in per type. Both defaults are rare, personal, and actionable; neither can fire more than a handful of times per tournament. This matches the seams already name-dropped in the `sendPushToPlayer` doc comment.
|
|
||||||
|
|
||||||
## How to wire one (when the picks are made)
|
|
||||||
|
|
||||||
The pattern is deliberately boring:
|
|
||||||
|
|
||||||
1. Find the emit site — e.g. `startMatch` in `src/features/matches/server.ts` already calls `emitServerEvent({ type: "match", ... })` when the admin starts a match.
|
|
||||||
2. Next to that emit, resolve the affected player IDs and call `sendPushToPlayer(playerId, { title, body, url })` from `src/lib/push/index.ts`, with `url` deep-linking to the match/tournament route. Fire-and-forget (don't block the server fn on push transport), use `tag` to collapse repeats of the same kind.
|
|
||||||
3. That's it — subscription storage, multi-device fan-out, and dead-endpoint pruning are already handled by the foundation.
|
|
||||||
|
|
||||||
## Follow-up idea: per-type preferences
|
|
||||||
|
|
||||||
Today `src/features/settings/components/notifications-section.tsx` is a single per-device switch. Once types are chosen, extend that section with **category toggles** (e.g. "My matches", "Announcements", "Social", "Results & recaps") persisted per *player* (not per device), and have each trigger check the player's category preference before calling `sendPushToPlayer`. Categories, not individual types — a settings screen with 15 switches is its own kind of annoying.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# Side Bets — Design Spec (v1, locked)
|
|
||||||
|
|
||||||
**Status:** design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
|
|
||||||
|
|
||||||
No real money, ever. This is an in-tournament engagement layer.
|
|
||||||
|
|
||||||
## Currency
|
|
||||||
- Each player gets **10 🪙 tokens fresh at the start of every tournament**. No carryover, **no seasons** (this app has no season concept).
|
|
||||||
- **Whole numbers only.**
|
|
||||||
- **No hard bust.** If you hit 0 tokens with matches remaining, you get a **1-token "comeback" bet each remaining match** so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
|
|
||||||
|
|
||||||
## When you can bet
|
|
||||||
- A match's book **opens when the match is *set*** (both teams populated → status `ready`).
|
|
||||||
- The book **closes the instant the admin *starts* the match** (status `started`) — same moment walkouts/announcements play. No bets after start.
|
|
||||||
- This dovetails with score reporting, which only begins once a match is `started`.
|
|
||||||
|
|
||||||
## Bet type 1 — Winner (social pool / parimutuel)
|
|
||||||
The core, social bet. You bet against your friends, not the house.
|
|
||||||
|
|
||||||
- Pick the winning team. Stake `1..(your tokens)`.
|
|
||||||
- All stakes on the match form **one pot**. On finalize, everyone who picked the actual winner **splits the whole pot proportional to their stake**; wrong picks forfeit their tokens to the pot.
|
|
||||||
- **Integer payout rule:** floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
|
|
||||||
- **House floor:** a correct bet always returns **at least ~1.5× the stake** even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
|
|
||||||
- **Upset dynamics are automatic:** betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
|
|
||||||
- **Edge cases:**
|
|
||||||
- Everyone picked the same team → no losers → **all stakes refunded** (no action).
|
|
||||||
- Nobody picked the winner → **everyone refunded**.
|
|
||||||
- Solo bettor → the **house floor** applies (this is exactly what it's for).
|
|
||||||
- **Live projected payout:** before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
|
|
||||||
|
|
||||||
## Bet type 2 — Overtime long shots (house-backed, fixed payout)
|
|
||||||
NOT a pool — you bet against a fixed multiplier, so including these does **not** thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match `ot_count` field (the app tracks multiple OTs):
|
|
||||||
|
|
||||||
| Bet | Wins if | Payout |
|
|
||||||
|---|---|---|
|
|
||||||
| Reaches OT | `ot_count ≥ 1` | **2×** |
|
|
||||||
| Reaches Double OT | `ot_count ≥ 2` | **4×** |
|
|
||||||
| Reaches Triple OT | `ot_count ≥ 3` | **8×** |
|
|
||||||
|
|
||||||
Each is an independent yes/no long shot; **each additional OT doubles the payout** (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
|
|
||||||
|
|
||||||
## Leaderboard & payoff
|
|
||||||
- **Per-tournament Side Bets leaderboard**, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
|
|
||||||
- A few **badges** (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
|
|
||||||
|
|
||||||
## Data model (proposed — finalize at build)
|
|
||||||
- New `bets` collection: `{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }`.
|
|
||||||
- **Settlement hooks into match finalization** (`finalizeMatch` / the `match` SSE): compute Winner pool splits + house-backed OT results, write payouts, update balances.
|
|
||||||
- Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
|
|
||||||
|
|
||||||
## UI/UX
|
|
||||||
- Bettable matches surface on the home **Upcoming** carousel (Wave 2) + the match card/dock.
|
|
||||||
- **Bet slip sheet:** choose market, stake slider, live projected payout.
|
|
||||||
- Win/loss **reveal animation** on settle; the reaction rail already exists next to matches.
|
|
||||||
- **Side Bets leaderboard screen** — mirror the existing predictions leaderboard component.
|
|
||||||
|
|
||||||
## Reuses / dependencies
|
|
||||||
- SSE bus, the predictions leaderboard pattern, the badges system.
|
|
||||||
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
|
|
||||||
|
|
||||||
## Side Bets on the home screen (reserved slot)
|
|
||||||
The Wave 2 home redesign **leaves a labeled slot for this** — build it here later:
|
|
||||||
- A compact **"Your Side Bets" card** near the top of the **Upcoming** section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
|
|
||||||
- On each card in the **Upcoming** carousel (matches that are `ready`, not yet `started`), a **"Bet" affordance** that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start.
|
|
||||||
- Once a match is `started`, its bets are locked; the card flips to showing the pool result live as it settles.
|
|
||||||
- The redesign should reserve this vertical space and (in code) leave a clear `{/* SIDE BETS SLOT — see docs/side-bets.md */}` marker where the card mounts, so wiring it in later is a drop-in.
|
|
||||||
|
|
||||||
## Implementation notes for a fresh agent (build this cold)
|
|
||||||
Context: worktree `social` branch; changes are typically left uncommitted for review. Read `docs/side-bets.md` (this file) fully first, then:
|
|
||||||
- **Server fns:** follow the `createServerFn(...).validator(zod).middleware([...]).handler(...)` + `toServerResult` pattern. Player-facing (non-admin) fns use `.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])` and resolve the caller via `getPlayerByAuthId(context.userAuthId)` — copy `toggleMatchReaction` in `src/features/matches/server.ts` as the template. All DB access is server-side via `pbAdmin`; collections have null rules.
|
|
||||||
- **Settlement:** hook into `finalizeMatch` in `src/features/matches/server.ts` (the single match-finalization path — admin `endMatch` and player `confirmMatchScore` both call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live.
|
|
||||||
- **Bet open/close** is derived from match `status`: bettable while `ready`, locked at `started` (identical trigger to score reporting).
|
|
||||||
- **Migration:** mimic an existing `pb_migrations/*_created_*.js` (e.g. `1784007613_created_predictions.js`) for the new `bets` collection — plain `migrate((app)=>{...})` with an up + down. Teams collection id `pbc_1568971955`, players `pbc_3072146508`, matches `pbc_2541054544`, tournaments `pbc_340646327`.
|
|
||||||
- **Client data:** use the `useServerQuery` / `useServerMutation` hooks (`src/lib/tanstack-query/hooks/*`) and the query-key convention seen in `src/features/predictions/queries.ts`. Add the new event type to `src/lib/events/emitter.ts` + handle it in `src/hooks/use-server-events.ts` (invalidate side-bets keys, mirroring how `match`/`reaction` are handled).
|
|
||||||
- **Leaderboard UI:** mirror `src/features/predictions/components/prediction-leaderboard.tsx` and its route `src/app/routes/_authed/tournaments/$id.predictions.tsx` for a `$id.side-bets.tsx` route.
|
|
||||||
- **Toasts:** `import { toast } from "@/lib/sonner"` — `toast.success` / `toast.error` only.
|
|
||||||
- **New feature dir:** `src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}` — keep pure logic (pool split, house floor, OT payout) in `utils.ts` with unit-testable functions, like `src/features/predictions/utils.ts`.
|
|
||||||
- **Design system:** Mantine 8 + the app theme; press feedback via the `flxn-press` theme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
|
|
||||||
|
|
||||||
## Tunables to revisit after first live use
|
|
||||||
- House floor multiplier (default **1.5×**).
|
|
||||||
- OT multipliers (**2× / 4× / 8×**).
|
|
||||||
- Comeback token amount (**1**).
|
|
||||||
- Whether an all-agreed pool refunds or voids.
|
|
||||||
@@ -6,7 +6,7 @@ metadata:
|
|||||||
app: flxn
|
app: flxn
|
||||||
component: app
|
component: app
|
||||||
spec:
|
spec:
|
||||||
replicas: 1 # Must stay at 1 for SSE
|
replicas: 1
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: flxn
|
app: flxn
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
vite_api_domain: "https://dev.flexxon.app"
|
vite_api_domain: "https://dev.flexxon.app"
|
||||||
vite_website_domain: "https://dev.flexxon.app"
|
vite_website_domain: "https://dev.flexxon.app"
|
||||||
supertokens_uri: "http://192.168.4.43:30568"
|
supertokens_uri: "http://192.168.0.50:30568"
|
||||||
pocketbase_url: "http://192.168.4.43:30096"
|
pocketbase_url: "http://192.168.0.50:30096"
|
||||||
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
|
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
|
||||||
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
|
vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback"
|
||||||
s3_endpoint: "https://s3.yohler.net"
|
s3_endpoint: "https://s3.yohler.net"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
vite_api_domain: "https://flexxon.app"
|
vite_api_domain: "https://flexxon.app"
|
||||||
vite_website_domain: "https://flexxon.app"
|
vite_website_domain: "https://flexxon.app"
|
||||||
supertokens_uri: "http://192.168.4.43:30568"
|
supertokens_uri: "http://192.168.0.50:30568"
|
||||||
pocketbase_url: "http://192.168.4.43:30097"
|
pocketbase_url: "http://192.168.0.50:30097"
|
||||||
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
|
vite_spotify_client_id: "3ffde6b594e84460b3d4b329b8919277"
|
||||||
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
|
vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback"
|
||||||
s3_endpoint: "https://s3.yohler.net"
|
s3_endpoint: "https://s3.yohler.net"
|
||||||
|
|||||||
@@ -5,55 +5,65 @@
|
|||||||
"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 && bun scripts/generate-sw.mjs",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "bun run server.ts"
|
"start": "bun run server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@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.101.2",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@tanstack/react-query-devtools": "^5.101.2",
|
"@tanstack/react-query-devtools": "^5.66.0",
|
||||||
"@tanstack/react-router": "^1.170.17",
|
"@tanstack/react-router": "^1.143.6",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.143.6",
|
||||||
"@tanstack/react-router-ssr-query": "^1.167.1",
|
"@tanstack/react-router-ssr-query": "^1.143.6",
|
||||||
"@tanstack/react-start": "^1.168.27",
|
"@tanstack/react-start": "^1.143.6",
|
||||||
|
"@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.2.7",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.0.0",
|
||||||
"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",
|
||||||
"web-push": "^3.6.7",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.0.15"
|
"zod": "^4.0.15",
|
||||||
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/router-plugin": "^1.132.2",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/react": "^19.2.17",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react": "^19.0.8",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@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",
|
||||||
@@ -61,7 +71,8 @@
|
|||||||
"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-build": "^7.4.1"
|
"workbox-window": "^7.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
/// <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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
migrate((app) => {
|
|
||||||
const collection = new Collection({
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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);
|
|
||||||
})
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/// <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)
|
|
||||||
})
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
migrate((app) => {
|
|
||||||
const collection = new Collection({
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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);
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/* 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);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
],
|
],
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#242424",
|
"theme_color": "#1e293b",
|
||||||
"background_color": "#242424",
|
"background_color": "#0f172a",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"categories": ["games", "social", "beer pong"],
|
"categories": ["games", "social", "beer pong"],
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 226 KiB |
@@ -29,27 +29,3 @@
|
|||||||
[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); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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`,
|
|
||||||
)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// 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);
|
|
||||||
@@ -343,10 +343,7 @@ async function initializeStaticRoutes(
|
|||||||
gz,
|
gz,
|
||||||
etag,
|
etag,
|
||||||
type: metadata.type,
|
type: metadata.type,
|
||||||
// Only Vite's content-hashed output under /assets/ is safe to
|
immutable: true,
|
||||||
// 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)
|
||||||
@@ -532,9 +529,14 @@ 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 {
|
||||||
// Return the handler's Response as-is so streaming bodies
|
const h3Response = await handler.fetch(req)
|
||||||
// (SSR streaming, SSE event streams) pass through unbuffered.
|
|
||||||
return await handler.fetch(req)
|
const body = await h3Response.arrayBuffer()
|
||||||
|
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 })
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ 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'
|
||||||
@@ -41,13 +38,10 @@ 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'
|
||||||
@@ -152,21 +146,6 @@ 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/$',
|
||||||
@@ -214,12 +193,6 @@ 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',
|
||||||
@@ -244,18 +217,6 @@ 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',
|
||||||
@@ -293,9 +254,6 @@ 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
|
||||||
@@ -308,13 +266,10 @@ 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
|
||||||
}
|
}
|
||||||
@@ -335,9 +290,6 @@ 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
|
||||||
@@ -350,13 +302,10 @@ 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
|
||||||
}
|
}
|
||||||
@@ -380,9 +329,6 @@ 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
|
||||||
@@ -395,13 +341,10 @@ 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
|
||||||
}
|
}
|
||||||
@@ -425,9 +368,6 @@ 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'
|
||||||
@@ -440,13 +380,10 @@ 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
|
||||||
@@ -467,9 +404,6 @@ 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'
|
||||||
@@ -482,13 +416,10 @@ 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:
|
||||||
@@ -511,9 +442,6 @@ 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'
|
||||||
@@ -526,13 +454,10 @@ 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
|
||||||
@@ -545,9 +470,6 @@ 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
|
||||||
@@ -701,27 +623,6 @@ 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/$'
|
||||||
@@ -785,13 +686,6 @@ 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'
|
||||||
@@ -820,20 +714,6 @@ 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'
|
||||||
@@ -899,9 +779,6 @@ 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 = {
|
||||||
@@ -916,11 +793,6 @@ 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 =
|
||||||
@@ -934,9 +806,6 @@ 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,
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ export function getRouter() {
|
|||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
},
|
},
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 60_000,
|
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultViewTransition: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setupRouterSsrQueryIntegration({
|
setupRouterSsrQueryIntegration({
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
isRedirect,
|
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
||||||
@@ -19,7 +18,6 @@ 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 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'
|
||||||
@@ -42,12 +40,13 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{
|
{
|
||||||
name: "viewport",
|
name: "viewport",
|
||||||
content:
|
content:
|
||||||
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content",
|
||||||
},
|
},
|
||||||
{ name: 'description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
{ name: 'description', content: 'Amicus meus madidus' },
|
||||||
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
|
||||||
|
{ name: 'theme-color', content: '#1e293b' },
|
||||||
{ property: 'og:title', content: 'FLXN' },
|
{ property: 'og:title', content: 'FLXN' },
|
||||||
{ property: 'og:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
{ property: 'og:description', content: 'Amicus meus madidus' },
|
||||||
{ property: 'og:url', content: 'https://flexxon.app' },
|
{ property: 'og:url', content: 'https://flexxon.app' },
|
||||||
{ property: 'og:type', content: 'website' },
|
{ property: 'og:type', content: 'website' },
|
||||||
{ property: 'og:site_name', content: 'FLXN' },
|
{ property: 'og:site_name', content: 'FLXN' },
|
||||||
@@ -58,11 +57,11 @@ export const Route = createRootRouteWithContext<{
|
|||||||
{ property: 'og:locale', content: 'en_US' },
|
{ property: 'og:locale', content: 'en_US' },
|
||||||
{ name: 'twitter:card', content: 'summary' },
|
{ name: 'twitter:card', content: 'summary' },
|
||||||
{ name: 'twitter:title', content: 'FLXN' },
|
{ name: 'twitter:title', content: 'FLXN' },
|
||||||
{ name: 'twitter:description', content: 'FLXN — beer pong tournaments, brackets, and stats.' },
|
{ name: 'twitter:description', content: 'Amicus meus madidus' },
|
||||||
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
{ name: 'twitter:image', content: 'https://flexxon.app/favicon.png' },
|
||||||
{ name: 'mobile-web-app-capable', content: 'yes' },
|
{ name: 'mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'default' },
|
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
||||||
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
|
{ name: 'apple-mobile-web-app-title', content: 'FLXN' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
@@ -123,27 +122,9 @@ export const Route = createRootRouteWithContext<{
|
|||||||
context.queryClient,
|
context.queryClient,
|
||||||
playerQueries.auth()
|
playerQueries.auth()
|
||||||
);
|
);
|
||||||
|
|
||||||
return { auth };
|
return { auth };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (isRedirect(error) || error instanceof Response) throw error;
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session');
|
|
||||||
|
|
||||||
const sessionExists = await doesSessionExist();
|
|
||||||
if (sessionExists) {
|
|
||||||
try {
|
|
||||||
await attemptRefreshingSession();
|
|
||||||
const auth = await ensureServerQueryData(
|
|
||||||
context.queryClient,
|
|
||||||
playerQueries.auth()
|
|
||||||
);
|
|
||||||
return { auth };
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -153,9 +134,6 @@ 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 (
|
||||||
@@ -182,16 +160,6 @@ 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>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
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 { Group, Skeleton, Stack } from "@mantine/core";
|
import { Flex, Loader } from "@mantine/core";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed")({
|
export const Route = createFileRoute("/_authed")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
|
console.log('_authed beforeLoad context:', context.auth);
|
||||||
|
|
||||||
if (!context.auth?.user) {
|
if (!context.auth?.user) {
|
||||||
|
console.log('_authed: No user in context, redirecting to login');
|
||||||
throw redirect({ to: "/login" });
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('_authed: User found, allowing access');
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
...context.auth,
|
...context.auth,
|
||||||
@@ -26,21 +30,9 @@ export const Route = createFileRoute("/_authed")({
|
|||||||
},
|
},
|
||||||
pendingComponent: () => (
|
pendingComponent: () => (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Stack gap="md" p="md" w="100%">
|
<Flex w='100%' h="40dvh" justify="center" align="flex-end">
|
||||||
<Group gap="sm">
|
<Loader size='xl' />
|
||||||
<Skeleton height={40} width={40} radius="sm" />
|
</Flex>
|
||||||
<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>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ 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 { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
|
import { Tabs } from "@mantine/core";
|
||||||
import { Suspense, useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/activities")({
|
export const Route = createFileRoute("/_authed/admin/activities")({
|
||||||
component: Stats,
|
component: Stats,
|
||||||
@@ -23,30 +23,6 @@ export const Route = createFileRoute("/_authed/admin/activities")({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
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");
|
||||||
|
|
||||||
@@ -58,15 +34,11 @@ function Stats() {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="server-functions">
|
<Tabs.Panel value="server-functions">
|
||||||
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
|
<ActivitiesTable />
|
||||||
<ActivitiesTable />
|
|
||||||
</Suspense>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="player-activity">
|
<Tabs.Panel value="player-activity">
|
||||||
<Suspense fallback={<ActivityRowsSkeleton />}>
|
<PlayersActivityTable />
|
||||||
<PlayersActivityTable />
|
|
||||||
</Suspense>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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, Skeleton } from "@mantine/core";
|
import { Stack, Text, Button, Alert, LoadingOverlay, Group } 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";
|
||||||
@@ -27,23 +27,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
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;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
@@ -24,26 +23,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
|
|||||||
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,7 +2,6 @@ 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";
|
|
||||||
|
|
||||||
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 }) => {
|
||||||
@@ -24,37 +23,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
|||||||
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,14 +1,12 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
prefetchServerQuery(queryClient, tournamentQueries.list());
|
await prefetchServerQuery(queryClient, tournamentQueries.list());
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -21,26 +19,6 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
|||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function TournamentListSkeleton() {
|
|
||||||
return (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
|
||||||
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
|
|
||||||
<Group p="md" wrap="nowrap" w="100%">
|
|
||||||
<Skeleton height={16} width="55%" radius="sm" />
|
|
||||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
|
||||||
</Group>
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return (
|
return <ManageTournaments />;
|
||||||
<Suspense fallback={<TournamentListSkeleton />}>
|
|
||||||
<ManageTournaments />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
|
import { Container, Stack, Divider, Title } 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";
|
||||||
@@ -37,46 +37,8 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
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);
|
||||||
@@ -97,16 +59,6 @@ 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: [] };
|
||||||
@@ -155,13 +107,11 @@ function RouteComponent() {
|
|||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
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">Knockout Bracket</Title>
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : hasGroupStage ? (
|
) : hasGroupStage ? (
|
||||||
@@ -172,11 +122,9 @@ function RouteComponent() {
|
|||||||
tournamentId={tournament.id}
|
tournamentId={tournament.id}
|
||||||
hasKnockoutBracket={knockoutBracketPopulated}
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
|
||||||
nextUpMatchId={nextUpMatchId}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
tournament.regional === true ? (
|
tournament.regional === true ? (
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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 { 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";
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ function RouteComponent() {
|
|||||||
<ColorSchemePicker />
|
<ColorSchemePicker />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
<NotificationsSection />
|
|
||||||
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
import PlayerStatsTable from "@/features/players/components/player-stats-table";
|
||||||
import { Suspense, useState, useDeferredValue, useEffect } from "react";
|
import { Suspense, useState, useDeferredValue } 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";
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -28,15 +29,6 @@ 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">
|
||||||
@@ -72,7 +64,7 @@ function Stats() {
|
|||||||
</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 fallback={<PlayerStatsTableSkeleton hideFilters />}>
|
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}>
|
||||||
<PlayerStatsTable viewType={deferredViewType} />
|
<PlayerStatsTable viewType={deferredViewType} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ 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 { groupMatchesIntoBracket } from "@/features/bracket/utils/group";
|
import { Match } from "@/features/matches/types";
|
||||||
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 }) => {
|
||||||
@@ -32,17 +31,46 @@ 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(() => {
|
||||||
() => groupMatchesIntoBracket(tournament.matches),
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
[tournament.matches]
|
return { winners: [], losers: [] };
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const winnersMap = new Map<number, Match[]>();
|
||||||
|
const losersMap = new Map<number, Match[]>();
|
||||||
|
|
||||||
|
tournament.matches
|
||||||
|
.filter((match) => match.round !== -1)
|
||||||
|
.sort((a, b) => a.lid - b.lid)
|
||||||
|
.forEach((match) => {
|
||||||
|
if (!match.is_losers_bracket) {
|
||||||
|
if (!winnersMap.has(match.round)) {
|
||||||
|
winnersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
winnersMap.get(match.round)!.push(match);
|
||||||
|
} else {
|
||||||
|
if (!losersMap.has(match.round)) {
|
||||||
|
losersMap.set(match.round, []);
|
||||||
|
}
|
||||||
|
losersMap.get(match.round)!.push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const winners = Array.from(winnersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
|
||||||
|
const losers = Array.from(losersMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, matches]) => matches);
|
||||||
|
return { winners, losers };
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
|
|||||||
@@ -5,7 +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 { Box, Card, Container, Group, Skeleton, SimpleGrid, Stack } from "@mantine/core";
|
import { Container } 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,46 +28,8 @@ 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);
|
||||||
@@ -78,7 +40,6 @@ function RouteComponent() {
|
|||||||
groups={tournament.groups || []}
|
groups={tournament.groups || []}
|
||||||
matches={tournament.matches || []}
|
matches={tournament.matches || []}
|
||||||
isRegional={tournament.regional}
|
isRegional={tournament.regional}
|
||||||
groupConfig={tournament.group_config}
|
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
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: "Predictions",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
component: RouteComponent,
|
|
||||||
});
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
const { data: tournament } = useTournament(id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container size="md" px={0}>
|
|
||||||
<PredictionLeaderboard tournament={tournament} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
||||||
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: `${context.prediction.player.first_name}'s Bracket`,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
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}>
|
|
||||||
PTS
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={700}>
|
|
||||||
{score.points}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Stack gap={0} ta="center">
|
|
||||||
<Text size="xs" c="dimmed" fw={700}>
|
|
||||||
PICKS
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{score.correct}/{score.total}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,13 +23,7 @@ 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
|
<Skeleton height="120px" w="100%" />
|
||||||
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 />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { serverEvents, EVENT_TYPES, type ServerEvent } from "@/lib/events/emitter";
|
import { serverEvents, 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: {
|
||||||
@@ -14,47 +13,63 @@ 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 send = (payload: unknown) => {
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
|
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||||
|
|
||||||
|
const handleEvent = (event: ServerEvent) => {
|
||||||
|
logger.info("ServerEvents | Event received", event);
|
||||||
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
try {
|
try {
|
||||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
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();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => send(event);
|
serverEvents.on("test", handleEvent);
|
||||||
for (const type of EVENT_TYPES) {
|
serverEvents.on("match", handleEvent);
|
||||||
serverEvents.on(type, handleEvent);
|
serverEvents.on("reaction", handleEvent);
|
||||||
}
|
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
send({ type: "ping", timestamp: Date.now() });
|
try {
|
||||||
|
if (!controller.desiredSize || controller.desiredSize <= 0) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pingMessage = `data: ${JSON.stringify({ type: "ping", timestamp: Date.now() })}\n\n`;
|
||||||
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("ServerEvents | Ping interval error", e);
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
cleanup = () => {
|
setTimeout(() => {
|
||||||
if (cleanedUp) return;
|
try {
|
||||||
cleanedUp = true;
|
const heartbeatMessage = `data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`;
|
||||||
activeConnections--;
|
controller.enqueue(new TextEncoder().encode(heartbeatMessage));
|
||||||
for (const type of EVENT_TYPES) {
|
} catch (e) {
|
||||||
serverEvents.off(type, handleEvent);
|
logger.error("ServerEvents | Heartbeat error", e);
|
||||||
}
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
activeConnections--;
|
||||||
|
serverEvents.off("test", handleEvent);
|
||||||
|
serverEvents.off("match", 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();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +77,13 @@ 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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
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: "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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -2,6 +2,7 @@ import LoginLayout from "@/features/login/components/layout";
|
|||||||
import LoginFlow from "@/features/login/components/login-flow";
|
import LoginFlow from "@/features/login/components/login-flow";
|
||||||
import { redirect, createFileRoute } from "@tanstack/react-router";
|
import { redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const loginSearchSchema = z.object({
|
const loginSearchSchema = z.object({
|
||||||
stage: z.enum(["code", "name"]).optional(),
|
stage: z.enum(["code", "name"]).optional(),
|
||||||
@@ -9,6 +10,36 @@ const loginSearchSchema = z.object({
|
|||||||
callback: z.string().optional(),
|
callback: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function LoginComponent() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
const accessTokenCookies = cookies.filter(c => c.trim().startsWith('sAccessToken='));
|
||||||
|
|
||||||
|
if (accessTokenCookies.length > 0) {
|
||||||
|
console.log('[Login] Clearing old SuperTokens cookies');
|
||||||
|
|
||||||
|
const cookieNames = ['sAccessToken', 'sRefreshToken', 'sIdRefreshToken', 'sFrontToken'];
|
||||||
|
const cookieDomain = (window as any).__COOKIE_DOMAIN__ || undefined;
|
||||||
|
|
||||||
|
cookieNames.forEach(name => {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
|
|
||||||
|
if (cookieDomain) {
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${cookieDomain}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginLayout>
|
||||||
|
<LoginFlow />
|
||||||
|
</LoginLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/login")({
|
export const Route = createFileRoute("/login")({
|
||||||
validateSearch: loginSearchSchema,
|
validateSearch: loginSearchSchema,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
@@ -16,11 +47,5 @@ export const Route = createFileRoute("/login")({
|
|||||||
throw redirect({ to: "/" });
|
throw redirect({ to: "/" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: () => {
|
component: LoginComponent,
|
||||||
return (
|
|
||||||
<LoginLayout>
|
|
||||||
<LoginFlow />
|
|
||||||
</LoginLayout>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import FullScreenLoader from '@/components/full-screen-loader'
|
import FullScreenLoader from '@/components/full-screen-loader'
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
import { refreshManager } from '@/lib/supertokens/refresh-manager'
|
||||||
import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
|
|
||||||
import { logger } from '@/lib/supertokens'
|
import { logger } from '@/lib/supertokens'
|
||||||
|
|
||||||
export const Route = createFileRoute('/refresh-session')({
|
export const Route = createFileRoute('/refresh-session')({
|
||||||
@@ -20,25 +19,16 @@ function RouteComponent() {
|
|||||||
try {
|
try {
|
||||||
logger.info("Refresh session route: starting refresh");
|
logger.info("Refresh session route: starting refresh");
|
||||||
|
|
||||||
const refreshed = await getOrCreateRefreshPromise(async () => {
|
const refreshed = await refreshManager.refresh();
|
||||||
return await attemptRefreshingSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
logger.info("Refresh session route: refresh successful");
|
logger.info("Refresh session route: refresh successful");
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const redirect = urlParams.get('redirect');
|
const redirect = urlParams.get('redirect');
|
||||||
|
|
||||||
const safe =
|
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
||||||
redirect &&
|
|
||||||
redirect.startsWith('/') &&
|
|
||||||
!redirect.startsWith('/refresh-session') &&
|
|
||||||
!redirect.includes('_serverFn') &&
|
|
||||||
!redirect.includes('/api/');
|
|
||||||
|
|
||||||
if (safe) {
|
|
||||||
logger.info("Refresh session route: redirecting to", redirect);
|
logger.info("Refresh session route: redirecting to", redirect);
|
||||||
window.location.href = redirect;
|
window.location.href = decodeURIComponent(redirect);
|
||||||
} else {
|
} else {
|
||||||
logger.info("Refresh session route: redirecting to home");
|
logger.info("Refresh session route: redirecting to home");
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
|
|||||||
@@ -98,21 +98,20 @@ const Avatar = ({
|
|||||||
>
|
>
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="white"
|
variant="filled"
|
||||||
color="dark"
|
color="dark"
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
aria-label="Close image preview"
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -10,
|
top: -10,
|
||||||
right: -10,
|
right: -10,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
}}
|
}}
|
||||||
onClick={() => setIsFullscreenOpen(false)}
|
onClick={() => setIsFullscreenOpen(false)}
|
||||||
>
|
>
|
||||||
<XIcon size={18} />
|
<XIcon size={18} color="white" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
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 classes from './countdown.module.css';
|
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -31,42 +30,25 @@ 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 now = useNow();
|
const now = useNow();
|
||||||
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||||
|
|
||||||
const totalSecondsLeft = Math.max(
|
const formatTime = () => {
|
||||||
0,
|
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||||
Math.floor((date.getTime() - now.getTime()) / 1000)
|
|
||||||
);
|
|
||||||
const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
|
|
||||||
|
|
||||||
const prefix =
|
if (timeLeft.days > 0) {
|
||||||
timeLeft.days > 0
|
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
} else {
|
||||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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
|
<Text size='sm' fw={600} c={color} ff="monospace">
|
||||||
size='sm'
|
{formatTime()}
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Box, Avatar as MantineAvatar } from "@mantine/core";
|
import { Paper, Box } 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 = "md",
|
radius = "100%",
|
||||||
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);
|
||||||
@@ -32,16 +37,13 @@ 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;
|
||||||
timeoutId = setTimeout(() => {
|
return setTimeout(() => {
|
||||||
setShowGlitch(true);
|
setShowGlitch(true);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
setTimeout(() => {
|
||||||
setShowGlitch(false);
|
setShowGlitch(false);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
scheduleNextGlitch();
|
scheduleNextGlitch();
|
||||||
@@ -49,7 +51,7 @@ const GlitchAvatar = ({
|
|||||||
}, delay);
|
}, delay);
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduleNextGlitch();
|
const timeoutId = scheduleNextGlitch();
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [glitchSrc]);
|
}, [glitchSrc]);
|
||||||
|
|
||||||
@@ -83,72 +85,92 @@ 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)`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{src ? (
|
<Box
|
||||||
<Box style={{ position: "relative" }}>
|
style={{
|
||||||
<img
|
opacity: showGlitch ? 0 : 1,
|
||||||
src={src}
|
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}
|
alt={name}
|
||||||
style={{
|
key={name}
|
||||||
display: "block",
|
name={name}
|
||||||
maxWidth: size,
|
color="initials"
|
||||||
maxHeight: size,
|
size={size}
|
||||||
width: "auto",
|
radius={radius}
|
||||||
height: "auto",
|
w={size}
|
||||||
borderRadius: innerRadius,
|
styles={{
|
||||||
opacity: showGlitch ? 0 : 1,
|
image: {
|
||||||
transition: showGlitch
|
objectFit: contain ? "contain" : "cover",
|
||||||
? "opacity 0.05s ease-in"
|
},
|
||||||
: "opacity 0.25s ease-out",
|
|
||||||
}}
|
}}
|
||||||
/>
|
src={src}
|
||||||
{glitchSrc && (
|
{...props}
|
||||||
|
>
|
||||||
|
{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={{
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={glitchSrc}
|
src={glitchSrc}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
width: `${size}px`,
|
||||||
inset: 0,
|
height: `${size}px`,
|
||||||
width: "100%",
|
objectFit: contain ? "contain" : "cover",
|
||||||
height: "100%",
|
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||||
objectFit: "contain",
|
display: "block",
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
import { Box } from "@mantine/core";
|
|
||||||
|
|
||||||
interface InfiniteScrollProps<T> {
|
|
||||||
items: T[];
|
|
||||||
renderItem: (item: T, index: number) => ReactNode;
|
|
||||||
batchSize?: number;
|
|
||||||
initialCount?: number;
|
|
||||||
rootMargin?: string;
|
|
||||||
loader?: ReactNode;
|
|
||||||
hasMore?: boolean;
|
|
||||||
loading?: boolean;
|
|
||||||
onLoadMore?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const InfiniteScroll = <T,>({
|
|
||||||
items,
|
|
||||||
renderItem,
|
|
||||||
batchSize = 25,
|
|
||||||
initialCount = batchSize,
|
|
||||||
rootMargin = "600px 0px",
|
|
||||||
loader,
|
|
||||||
hasMore = false,
|
|
||||||
loading = false,
|
|
||||||
onLoadMore,
|
|
||||||
}: InfiniteScrollProps<T>) => {
|
|
||||||
const [visibleCount, setVisibleCount] = useState(initialCount);
|
|
||||||
const [prevItems, setPrevItems] = useState(items);
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
if (prevItems !== items) {
|
|
||||||
setPrevItems(items);
|
|
||||||
setVisibleCount(initialCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasHiddenItems = visibleCount < items.length;
|
|
||||||
const showLoader = hasHiddenItems || hasMore || loading;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const sentinel = sentinelRef.current;
|
|
||||||
if (!sentinel) return;
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
||||||
|
|
||||||
if (visibleCount < items.length) {
|
|
||||||
setVisibleCount((count) => Math.min(count + batchSize, items.length));
|
|
||||||
} else if (hasMore && !loading) {
|
|
||||||
onLoadMore?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin }
|
|
||||||
);
|
|
||||||
|
|
||||||
observer.observe(sentinel);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [items, visibleCount, batchSize, hasMore, loading, onLoadMore, rootMargin]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{items.slice(0, visibleCount).map((item, index) => renderItem(item, index))}
|
|
||||||
{showLoader && (
|
|
||||||
<>
|
|
||||||
<Box ref={sentinelRef} />
|
|
||||||
{loader}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InfiniteScroll;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
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;
|
||||||
@@ -13,7 +12,6 @@ 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"}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import styles from "./list-row.module.css";
|
|
||||||
|
|
||||||
interface ListLinkProps {
|
interface ListLinkProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -16,7 +15,6 @@ 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"
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { refreshManager } from '@/lib/supertokens/refresh-manager';
|
||||||
import { doesSessionExist } from 'supertokens-web-js/recipe/session';
|
|
||||||
import { getOrCreateRefreshPromise } from '@/lib/supertokens/client';
|
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session';
|
|
||||||
import { logger } from '@/lib/supertokens';
|
import { logger } from '@/lib/supertokens';
|
||||||
|
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client';
|
||||||
|
|
||||||
export function SessionMonitor() {
|
export function SessionMonitor() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const lastRefreshTimeRef = useRef<number>(0);
|
const lastRefreshTimeRef = useRef<number>(0);
|
||||||
const REFRESH_COOLDOWN = 30 * 1000;
|
const REFRESH_COOLDOWN = 5 * 1000;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -22,23 +19,27 @@ export function SessionMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
|
const timeSinceLastRefresh = now - lastRefreshTimeRef.current;
|
||||||
logger.info('Session monitor: skipping refresh (cooldown)');
|
|
||||||
|
if (timeSinceLastRefresh < REFRESH_COOLDOWN) {
|
||||||
|
logger.info(`Session monitor: skipping refresh (refreshed ${timeSinceLastRefresh}ms ago)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
ensureSuperTokensFrontend();
|
||||||
|
|
||||||
|
const { doesSessionExist } = await import('supertokens-web-js/recipe/session');
|
||||||
|
|
||||||
const sessionExists = await doesSessionExist();
|
const sessionExists = await doesSessionExist();
|
||||||
if (!sessionExists) {
|
if (!sessionExists) {
|
||||||
logger.info('Session monitor: no session exists, skipping refresh');
|
logger.info('Session monitor: no session exists, skipping refresh');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Session monitor: tab became visible, refreshing session');
|
logger.info('Session monitor: tab became visible, checking session freshness');
|
||||||
|
|
||||||
const refreshed = await getOrCreateRefreshPromise(async () => {
|
const refreshed = await refreshManager.refresh();
|
||||||
return await attemptRefreshingSession();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
lastRefreshTimeRef.current = Date.now();
|
lastRefreshTimeRef.current = Date.now();
|
||||||
@@ -46,19 +47,17 @@ export function SessionMonitor() {
|
|||||||
} else {
|
} else {
|
||||||
logger.warn('Session monitor: refresh returned false');
|
logger.warn('Session monitor: refresh returned false');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error('Session monitor: error refreshing session', error);
|
logger.error('Session monitor: error refreshing session', error?.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleVisibilityChange();
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [navigate]);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
import { PropsWithChildren, useEffect, useRef } from "react";
|
import { PropsWithChildren, useEffect, useRef } from "react";
|
||||||
import { Drawer as VaulDrawer } from "vaul";
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
|
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
onClose?: () => void;
|
|
||||||
onExited?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Drawer: React.FC<DrawerProps> = ({
|
const Drawer: React.FC<DrawerProps> = ({
|
||||||
@@ -17,25 +19,55 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
children,
|
children,
|
||||||
opened,
|
opened,
|
||||||
onChange,
|
onChange,
|
||||||
onExited,
|
|
||||||
}) => {
|
}) => {
|
||||||
const colorScheme = useComputedColorScheme("light");
|
const colorScheme = useComputedColorScheme("light");
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const openedRef = useRef(opened);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) return;
|
openedRef.current = opened;
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
const appElement = document.querySelector<HTMLElement>(".app");
|
useEffect(() => {
|
||||||
const colors = CHROME_COLORS[colorScheme];
|
const appElement = document.querySelector(".app") as HTMLElement;
|
||||||
|
|
||||||
appElement?.classList.add("drawer-scaling");
|
if (!appElement) return;
|
||||||
setThemeColorMeta(colors.dimmed);
|
|
||||||
|
let themeColorMeta = document.querySelector(
|
||||||
|
'meta[name="theme-color"]'
|
||||||
|
) as HTMLMetaElement;
|
||||||
|
if (!themeColorMeta) {
|
||||||
|
themeColorMeta = document.createElement("meta");
|
||||||
|
themeColorMeta.name = "theme-color";
|
||||||
|
document.head.appendChild(themeColorMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
light: {
|
||||||
|
normal: "rgb(255,255,255)",
|
||||||
|
overlay: "rgb(153,153,153)",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
normal: "rgb(36,36,36)",
|
||||||
|
overlay: "rgb(22,22,22)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentColors = colors[colorScheme] || colors.light;
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
appElement.classList.add("drawer-scaling");
|
||||||
|
themeColorMeta.content = currentColors.overlay;
|
||||||
|
} else {
|
||||||
|
appElement.classList.remove("drawer-scaling");
|
||||||
|
themeColorMeta.content = currentColors.normal;
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
appElement?.classList.remove("drawer-scaling");
|
appElement.classList.remove("drawer-scaling");
|
||||||
setThemeColorMeta(colors.base);
|
themeColorMeta.content = currentColors.normal;
|
||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [opened]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened || !contentRef.current) return;
|
if (!opened || !contentRef.current) return;
|
||||||
@@ -77,9 +109,6 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
repositionInputs={false}
|
repositionInputs={false}
|
||||||
open={opened}
|
open={opened}
|
||||||
onOpenChange={onChange}
|
onOpenChange={onChange}
|
||||||
onAnimationEnd={(open) => {
|
|
||||||
if (!open) onExited?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
|
|||||||
@@ -5,30 +5,13 @@ interface ModalProps extends PropsWithChildren {
|
|||||||
title?: string;
|
title?: string;
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onChange?: (next: boolean) => void;
|
|
||||||
onExited?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({
|
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
||||||
title,
|
|
||||||
children,
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
onExited,
|
|
||||||
}) => (
|
|
||||||
<MantineModal
|
<MantineModal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Title order={3}>{title}</Title>}
|
title={<Title order={3}>{title}</Title>}
|
||||||
radius={20}
|
|
||||||
transitionProps={{
|
|
||||||
transition: "pop",
|
|
||||||
duration: 200,
|
|
||||||
timingFunction: "ease-out",
|
|
||||||
onExited,
|
|
||||||
}}
|
|
||||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
|
||||||
closeButtonProps={{ "aria-label": "Close" }}
|
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { PropsWithChildren, Suspense, useCallback } from "react";
|
||||||
PropsWithChildren,
|
|
||||||
Suspense,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import Drawer from "./drawer";
|
import Drawer from "./drawer";
|
||||||
import Modal from "./modal";
|
import Modal from "./modal";
|
||||||
@@ -17,35 +10,13 @@ interface SheetProps extends PropsWithChildren {
|
|||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAWER_EXIT_MS = 500;
|
|
||||||
const MODAL_EXIT_MS = 200;
|
|
||||||
const EXIT_BUFFER_MS = 100;
|
|
||||||
|
|
||||||
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const handleClose = useCallback(() => onChange(false), [onChange]);
|
const handleClose = useCallback(() => onChange(false), [onChange]);
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(opened);
|
|
||||||
const openedRef = useRef(opened);
|
|
||||||
openedRef.current = opened;
|
|
||||||
const handleExited = useCallback(() => {
|
|
||||||
if (!openedRef.current) setMounted(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (opened) {
|
|
||||||
setMounted(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exitDuration =
|
|
||||||
(isMobile ? DRAWER_EXIT_MS : MODAL_EXIT_MS) + EXIT_BUFFER_MS;
|
|
||||||
const timer = window.setTimeout(() => setMounted(false), exitDuration);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [opened, isMobile]);
|
|
||||||
|
|
||||||
const SheetComponent = isMobile ? Drawer : Modal;
|
const SheetComponent = isMobile ? Drawer : Modal;
|
||||||
|
|
||||||
if (!opened && !mounted) return null;
|
if (!opened) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetComponent
|
<SheetComponent
|
||||||
@@ -53,7 +24,6 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onExited={handleExited}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
|
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
|
||||||
|
|||||||
@@ -150,11 +150,7 @@ const SlidePanel = ({
|
|||||||
{panelConfig && (
|
{panelConfig && (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" p="md" align="center" w="100%">
|
<Group justify="space-between" p="md" align="center" w="100%">
|
||||||
<ActionIcon
|
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||||
variant="transparent"
|
|
||||||
onClick={closePanel}
|
|
||||||
aria-label="Back"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon size={24} />
|
<ArrowLeftIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Text fw={500}>{panelConfig.title}</Text>
|
<Text fw={500}>{panelConfig.title}</Text>
|
||||||
@@ -162,7 +158,6 @@ const SlidePanel = ({
|
|||||||
variant="transparent"
|
variant="transparent"
|
||||||
color="green"
|
color="green"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
aria-label="Confirm"
|
|
||||||
>
|
>
|
||||||
<CheckIcon size={24} />
|
<CheckIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -20,9 +20,3 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.drawerContent {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,11 +57,6 @@ function SwipeableTabs({
|
|||||||
embla?.scrollTo(index);
|
embla?.scrollTo(index);
|
||||||
onTabChange?.(index, tabs[index]);
|
onTabChange?.(index, tabs[index]);
|
||||||
|
|
||||||
document
|
|
||||||
.getElementById("scroll-wrapper")
|
|
||||||
?.querySelector(".mantine-ScrollArea-viewport")
|
|
||||||
?.scrollTo({ top: 0 });
|
|
||||||
|
|
||||||
const tabLabel = tabs[index].label.toLowerCase();
|
const tabLabel = tabs[index].label.toLowerCase();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { forwardRef } from "react";
|
|
||||||
import type { Icon, IconProps } from "@phosphor-icons/react";
|
|
||||||
|
|
||||||
export const WizardOrbIcon = forwardRef<SVGSVGElement, IconProps>(
|
|
||||||
(
|
|
||||||
{ size = 24, color = "currentColor", weight = "regular", mirrored, alt, style, ...rest },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const strokeWidth = weight === "bold" ? 20 : 16;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
ref={ref}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 256 256"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
fill="none"
|
|
||||||
style={{
|
|
||||||
transform: mirrored ? "scale(-1, 1)" : undefined,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
{...(alt ? { role: "img", "aria-label": alt } : { "aria-hidden": true })}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="128"
|
|
||||||
cy="112"
|
|
||||||
r="66"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
fill={weight === "duotone" ? color : "none"}
|
|
||||||
fillOpacity={weight === "duotone" ? 0.18 : undefined}
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M94 96 a 44 44 0 0 1 30 -26"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={strokeWidth * 0.7}
|
|
||||||
strokeLinecap="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M96 194 h64 l16 26 h-96 z"
|
|
||||||
stroke={color}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
strokeLinejoin="round"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M208 26 l8 16 16 8 -16 8 -8 16 -8 -16 -16 -8 16 -8 z"
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
) as Icon;
|
|
||||||
|
|
||||||
export default WizardOrbIcon;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
||||||
import { SpotifyAuth } from '@/lib/spotify/auth';
|
import { SpotifyAuth } from '@/lib/spotify/auth';
|
||||||
import { useAuth } from './auth-context';
|
import { useAuth } from './auth-context';
|
||||||
import { useConfig } from '@/hooks/use-config';
|
import { useConfig } from '@/hooks/use-config';
|
||||||
@@ -21,62 +21,6 @@ const defaultSpotifyState: SpotifyAuthState = {
|
|||||||
|
|
||||||
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
||||||
|
|
||||||
const deepEqual = (a: unknown, b: unknown): boolean => {
|
|
||||||
if (a === b) return true;
|
|
||||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
||||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
|
||||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
|
||||||
if (aKeys.length !== bKeys.length) return false;
|
|
||||||
return aKeys.every((key) =>
|
|
||||||
deepEqual(
|
|
||||||
(a as Record<string, unknown>)[key],
|
|
||||||
(b as Record<string, unknown>)[key]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
|
||||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorMessage = 'Request failed';
|
|
||||||
try {
|
|
||||||
const errorData = await response.json();
|
|
||||||
errorMessage = errorData.error || errorMessage;
|
|
||||||
} catch {
|
|
||||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
if (!contentType.includes('application/json')) {
|
|
||||||
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
@@ -97,9 +41,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
|
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
|
||||||
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
||||||
|
|
||||||
const spotifyAuth = useMemo(
|
const spotifyAuth = new SpotifyAuth(
|
||||||
() => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri),
|
config.spotifyClientId,
|
||||||
[config.spotifyClientId, config.spotifyRedirectUri]
|
config.spotifyRedirectUri
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,6 +126,45 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||||
|
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = 'Request failed';
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const play = useCallback(async (deviceId?: string) => {
|
const play = useCallback(async (deviceId?: string) => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
@@ -368,16 +351,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
const data = await makeSpotifyRequest('playback?type=state');
|
const data = await makeSpotifyRequest('playback?type=state');
|
||||||
const state = data.playbackState;
|
const state = data.playbackState;
|
||||||
|
|
||||||
setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state));
|
setPlaybackState(state);
|
||||||
setCurrentTrack((prev) => {
|
setCurrentTrack(state?.item || null);
|
||||||
const next = state?.item || null;
|
|
||||||
return deepEqual(prev, next) ? prev : next;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (state?.device) {
|
if (state?.device) {
|
||||||
setActiveDeviceState((prev) =>
|
setActiveDeviceState(state.device);
|
||||||
deepEqual(prev, state.device) ? prev : state.device
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to refresh playback state:', error);
|
console.warn('Failed to refresh playback state:', error);
|
||||||
@@ -387,24 +365,8 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
const poll = () => {
|
const interval = setInterval(refreshPlaybackState, 5000);
|
||||||
if (document.hidden) return;
|
return () => clearInterval(interval);
|
||||||
refreshPlaybackState();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (!document.hidden) {
|
|
||||||
refreshPlaybackState();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const interval = setInterval(poll, 5000);
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
};
|
|
||||||
}, [authState.isAuthenticated, refreshPlaybackState]);
|
}, [authState.isAuthenticated, refreshPlaybackState]);
|
||||||
|
|
||||||
const capturePlaybackState = useCallback(async () => {
|
const capturePlaybackState = useCallback(async () => {
|
||||||
@@ -470,7 +432,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const contextValue: SpotifyContextType = useMemo(() => ({
|
const contextValue: SpotifyContextType = {
|
||||||
...authState,
|
...authState,
|
||||||
currentTrack,
|
currentTrack,
|
||||||
playbackState,
|
playbackState,
|
||||||
@@ -499,33 +461,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
clearCapturedState,
|
clearCapturedState,
|
||||||
// Search
|
// Search
|
||||||
searchTracks,
|
searchTracks,
|
||||||
}), [
|
};
|
||||||
authState,
|
|
||||||
currentTrack,
|
|
||||||
playbackState,
|
|
||||||
devices,
|
|
||||||
activeDevice,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
capturedState,
|
|
||||||
isCaptureLoading,
|
|
||||||
isResumeLoading,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
play,
|
|
||||||
playTrack,
|
|
||||||
pause,
|
|
||||||
skipNext,
|
|
||||||
skipPrevious,
|
|
||||||
setVolume,
|
|
||||||
getDevices,
|
|
||||||
setActiveDevice,
|
|
||||||
refreshPlaybackState,
|
|
||||||
capturePlaybackState,
|
|
||||||
resumePlaybackState,
|
|
||||||
clearCapturedState,
|
|
||||||
searchTracks,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import {
|
|||||||
Pagination,
|
Pagination,
|
||||||
Code,
|
Code,
|
||||||
Alert,
|
Alert,
|
||||||
ThemeIcon,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -22,7 +20,6 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
ChecksIcon,
|
ChecksIcon,
|
||||||
PulseIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { Activity, ActivitySearchParams } from "../types";
|
import { Activity, ActivitySearchParams } from "../types";
|
||||||
import { useActivities } from "../queries";
|
import { useActivities } from "../queries";
|
||||||
@@ -223,14 +220,9 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{result.items.length === 0 && (
|
{result.items.length === 0 && (
|
||||||
<Stack align="center" gap="md" py="xl">
|
<Text ta="center" c="dimmed" py="xl">
|
||||||
<ThemeIcon size="xl" variant="light" radius="md">
|
No activities found
|
||||||
<PulseIcon size={32} />
|
</Text>
|
||||||
</ThemeIcon>
|
|
||||||
<Title order={3} c="dimmed">
|
|
||||||
No Activities Found
|
|
||||||
</Title>
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result.totalPages > 1 && (
|
{result.totalPages > 1 && (
|
||||||
@@ -347,7 +339,7 @@ export const ActivitiesTable = () => {
|
|||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
fw={sortBy.includes("created") ? 600 : 400}
|
fw={sortBy.includes("created") ? 600 : 400}
|
||||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||||
>
|
>
|
||||||
Date
|
Date
|
||||||
</Text>
|
</Text>
|
||||||
@@ -363,7 +355,7 @@ export const ActivitiesTable = () => {
|
|||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
fw={sortBy.includes("duration") ? 600 : 400}
|
fw={sortBy.includes("duration") ? 600 : 400}
|
||||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||||
>
|
>
|
||||||
Duration
|
Duration
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const activitySearchParamsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const searchActivities = createServerFn()
|
export const searchActivities = createServerFn()
|
||||||
.validator(activitySearchParamsSchema)
|
.inputValidator(activitySearchParamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<ActivityListResult>(async () => {
|
toServerResult<ActivityListResult>(async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const ManageTournaments = () => {
|
|||||||
return (
|
return (
|
||||||
<List p="0">
|
<List p="0">
|
||||||
{tournaments.map((t) => (
|
{tournaments.map((t) => (
|
||||||
<ListLink key={t.id} label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
);
|
);
|
||||||
|
|||||||