diff --git a/bun.lock b/bun.lock
index c27c08b..9a36ac2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -30,6 +30,7 @@
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
+ "facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
@@ -710,6 +711,8 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
+ "facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
diff --git a/package.json b/package.json
index c3bac09..2361e11 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
+ "facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index e630790..03b49f4 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -11,6 +11,7 @@ import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
+import { SessionMonitor } from "@/components/session-monitor";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
@@ -126,6 +127,7 @@ function RootComponent() {
return (
+
diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index 25034a1..3de4ccf 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -86,6 +86,7 @@ function RouteComponent() {
)}
diff --git a/src/app/routes/refresh-session.tsx b/src/app/routes/refresh-session.tsx
index dad66b0..9d627ed 100644
--- a/src/app/routes/refresh-session.tsx
+++ b/src/app/routes/refresh-session.tsx
@@ -2,7 +2,8 @@ import { createFileRoute } from '@tanstack/react-router'
import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
-import { resetRefreshFlag } from '@/lib/supertokens/client'
+import { resetRefreshFlag, getOrCreateRefreshPromise } from '@/lib/supertokens/client'
+import { logger } from '@/lib/supertokens'
export const Route = createFileRoute('/refresh-session')({
component: RouteComponent,
@@ -17,23 +18,31 @@ function RouteComponent() {
const handleRefresh = async () => {
try {
- resetRefreshFlag();
- const refreshed = await attemptRefreshingSession()
+ logger.info("Refresh session route: starting refresh");
+
+ const refreshed = await getOrCreateRefreshPromise(async () => {
+ return await attemptRefreshingSession();
+ });
if (refreshed) {
- const urlParams = new URLSearchParams(window.location.search)
- const redirect = urlParams.get('redirect')
+ logger.info("Refresh session route: refresh successful");
+ const urlParams = new URLSearchParams(window.location.search);
+ const redirect = urlParams.get('redirect');
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
- window.location.href = decodeURIComponent(redirect)
+ logger.info("Refresh session route: redirecting to", redirect);
+ window.location.href = decodeURIComponent(redirect);
} else {
+ logger.info("Refresh session route: redirecting to home");
window.location.href = '/';
}
} else {
- window.location.href = '/login'
+ logger.warn("Refresh session route: refresh failed, redirecting to login");
+ window.location.href = '/login';
}
} catch (error) {
- window.location.href = '/login'
+ logger.error("Refresh session route: error during refresh", error);
+ window.location.href = '/login';
}
}
diff --git a/src/components/player-avatar.tsx b/src/components/player-avatar.tsx
new file mode 100644
index 0000000..a8006ca
--- /dev/null
+++ b/src/components/player-avatar.tsx
@@ -0,0 +1,85 @@
+import { Paper, useMantineTheme } from "@mantine/core";
+import { Facehash } from "facehash";
+
+interface PlayerAvatarProps {
+ name?: string;
+ size?: number;
+ disableFullscreen?: boolean;
+ style?: React.CSSProperties;
+}
+
+const PlayerAvatar = ({
+ name = "",
+ size = 35,
+ disableFullscreen = false,
+ style,
+}: PlayerAvatarProps) => {
+ const theme = useMantineTheme();
+
+ const getFacehashSize = (size: number): 32 | 48 | 64 | 80 => {
+ if (size <= 40) return 32;
+ if (size <= 56) return 48;
+ if (size <= 72) return 64;
+ return 80;
+ };
+
+ const facehashSize = getFacehashSize(size);
+
+ const colors = [
+ "hsla(314, 100%, 80%, 1)",
+ "hsla(58, 93%, 72%, 1)",
+ "hsla(218, 92%, 72%, 1)",
+ "hsla(19, 99%, 44%, 1)",
+ "hsla(156, 86%, 40%, 1)",
+ "hsla(314, 100%, 85%, 1)",
+ "hsla(58, 92%, 79%, 1)",
+ "hsla(218, 91%, 78%, 1)",
+ "hsla(19, 99%, 50%, 1)",
+ "hsla(156, 86%, 64%, 1)",
+ ];
+
+ return (
+ {
+ if (!disableFullscreen) {
+ e.currentTarget.style.transform = 'scale(1.02)';
+ }
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'scale(1)';
+ }}
+ >
+
+
+
+
+ );
+};
+
+export default PlayerAvatar;
diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx
new file mode 100644
index 0000000..ee4e3ec
--- /dev/null
+++ b/src/components/session-monitor.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef } from 'react';
+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';
+
+export function SessionMonitor() {
+ const lastRefreshTimeRef = useRef(0);
+ const REFRESH_COOLDOWN = 30 * 1000;
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return;
+
+ const handleVisibilityChange = async () => {
+ if (document.visibilityState !== 'visible') return;
+
+ const publicRoutes = ['/login', '/logout', '/refresh-session'];
+ if (publicRoutes.some(route => window.location.pathname === route)) {
+ return;
+ }
+
+ const now = Date.now();
+ if (now - lastRefreshTimeRef.current < REFRESH_COOLDOWN) {
+ logger.info('Session monitor: skipping refresh (cooldown)');
+ return;
+ }
+
+ try {
+ const sessionExists = await doesSessionExist();
+ if (!sessionExists) {
+ logger.info('Session monitor: no session exists, skipping refresh');
+ return;
+ }
+
+ logger.info('Session monitor: tab became visible, refreshing session');
+
+ const refreshed = await getOrCreateRefreshPromise(async () => {
+ return await attemptRefreshingSession();
+ });
+
+ if (refreshed) {
+ lastRefreshTimeRef.current = Date.now();
+ logger.info('Session monitor: session refreshed successfully');
+ } else {
+ logger.warn('Session monitor: refresh returned false');
+ }
+ } catch (error) {
+ logger.error('Session monitor: error refreshing session', error);
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, []);
+
+ return null;
+}
diff --git a/src/components/team-avatar.tsx b/src/components/team-avatar.tsx
new file mode 100644
index 0000000..a639936
--- /dev/null
+++ b/src/components/team-avatar.tsx
@@ -0,0 +1,139 @@
+import { Box, AvatarGroup } from "@mantine/core";
+import { CrownIcon } from "@phosphor-icons/react";
+import { TeamInfo } from "@/features/teams/types";
+import Avatar from "./avatar";
+import PlayerAvatar from "./player-avatar";
+
+interface TeamAvatarProps {
+ team: TeamInfo;
+ size?: number;
+ radius?: string | number;
+ withBorder?: boolean;
+ disableFullscreen?: boolean;
+ contain?: boolean;
+ style?: React.CSSProperties;
+ winner?: boolean;
+ isRegional?: boolean;
+}
+
+const TeamAvatar = ({
+ team,
+ size = 35,
+ radius = "sm",
+ withBorder = true,
+ disableFullscreen = false,
+ contain = false,
+ style,
+ winner = false,
+ isRegional,
+}: TeamAvatarProps) => {
+ const hasNoLogo = !team.logo;
+ const hasTwoPlayers = team.players?.length === 2;
+
+ let shouldShowPlayerAvatars = false;
+
+ if (isRegional !== undefined) {
+ shouldShowPlayerAvatars = isRegional && hasTwoPlayers && hasNoLogo;
+ } else {
+ const tournaments = (team as any).tournaments;
+ const hasTournaments = tournaments && tournaments.length > 0;
+ const allTournamentsAreRegional = hasTournaments && tournaments.every((t: any) => t.regional === true);
+
+ shouldShowPlayerAvatars = hasTwoPlayers && hasNoLogo && (allTournamentsAreRegional || !hasTournaments);
+ }
+
+ if (shouldShowPlayerAvatars && team.players?.length === 2) {
+ const playerSize = size * 0.6;
+ const crownSize = Math.max(12, size * 0.35);
+
+ return (
+
+
+
+
+ {winner && (
+
+
+
+ )}
+
+
+
+ {winner && (
+
+
+
+ )}
+
+
+
+ );
+ }
+
+ const crownSize = Math.max(14, size * 0.4);
+
+ return (
+
+
+ {winner && (
+
+
+
+ )}
+
+ );
+};
+
+export default TeamAvatar;
diff --git a/src/features/core/components/pullable.tsx b/src/features/core/components/pullable.tsx
index 043f9cc..6fb4c00 100644
--- a/src/features/core/components/pullable.tsx
+++ b/src/features/core/components/pullable.tsx
@@ -85,6 +85,55 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP
return () => void ac.abort();
}, []);
+ // Fix wheel scrolling over child elements
+ useEffect(() => {
+ const scrollWrapper = document.getElementById('scroll-wrapper');
+ if (!scrollWrapper) return;
+
+ const viewport = scrollWrapper.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
+ if (!viewport) return;
+
+ const handleWheel = (e: WheelEvent) => {
+ const target = e.target as HTMLElement;
+
+ // Check if the target is inside a nested scrollable container
+ let element = target;
+ while (element && element !== viewport) {
+ const overflow = window.getComputedStyle(element).overflow;
+ const overflowY = window.getComputedStyle(element).overflowY;
+ const overflowX = window.getComputedStyle(element).overflowX;
+
+ // If we found a scrollable ancestor (not the main viewport), don't interfere
+ if (
+ (overflow === 'auto' || overflow === 'scroll' ||
+ overflowY === 'auto' || overflowY === 'scroll' ||
+ overflowX === 'auto' || overflowX === 'scroll') &&
+ element !== viewport
+ ) {
+ // Check if this element can actually scroll in the wheel direction
+ const canScrollY = element.scrollHeight > element.clientHeight;
+ const canScrollX = element.scrollWidth > element.clientWidth;
+
+ if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) {
+ return; // Let the nested scroller handle it
+ }
+ }
+
+ element = element.parentElement as HTMLElement;
+ }
+
+ // No nested scroller found, scroll the main viewport
+ viewport.scrollTop += e.deltaY;
+ viewport.scrollLeft += e.deltaX;
+ };
+
+ scrollWrapper.addEventListener('wheel', handleWheel, { passive: true });
+
+ return () => {
+ scrollWrapper.removeEventListener('wheel', handleWheel);
+ };
+ }, []);
+
useEffect(() => {
const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) {
@@ -129,8 +178,15 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
+ styles={{
+ viewport: {
+ '& > *': {
+ pointerEvents: 'auto'
+ }
+ }
+ }}
>
-
+
{children}
diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx
index 827942c..37d3ebe 100644
--- a/src/features/matches/components/match-card.tsx
+++ b/src/features/matches/components/match-card.tsx
@@ -1,8 +1,8 @@
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip, ActionIcon } from "@mantine/core";
-import { CrownIcon, FootballHelmetIcon } from "@phosphor-icons/react";
+import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
-import Avatar from "@/components/avatar";
+import TeamAvatar from "@/components/team-avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet";
@@ -113,32 +113,16 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
-
- {isHomeWin && (
-
-
-
- )}
{
-
- {isAwayWin && (
-
-
-
- )}
{
const [player1Id, setPlayer1Id] = useState(null);
@@ -89,7 +89,7 @@ const LeagueHeadToHead = () => {
{player1Id ? (
<>
-
+
{player1Name}
@@ -110,7 +110,7 @@ const LeagueHeadToHead = () => {
>
) : (
-
+
Player 1
@@ -145,7 +145,7 @@ const LeagueHeadToHead = () => {
{player2Id ? (
<>
-
+
{player2Name}
@@ -166,7 +166,7 @@ const LeagueHeadToHead = () => {
>
) : (
-
+
Player 2
@@ -241,7 +241,7 @@ const LeagueHeadToHead = () => {
},
}}
>
-
+
{player.player_name}
diff --git a/src/features/players/components/player-list.tsx b/src/features/players/components/player-list.tsx
index af50143..ad2cd68 100644
--- a/src/features/players/components/player-list.tsx
+++ b/src/features/players/components/player-list.tsx
@@ -1,6 +1,6 @@
import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router";
-import Avatar from "@/components/avatar";
+import PlayerAvatar from "@/components/player-avatar";
import { Player } from "@/features/players/types";
import { useCallback } from "react";
@@ -29,7 +29,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
{players?.map((player) => (
}
+ icon={}
style={{ cursor: 'pointer' }}
onClick={() => handleClick(player.id)}
>
diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx
index f2b9fba..e362c43 100644
--- a/src/features/players/components/player-stats-table.tsx
+++ b/src/features/players/components/player-stats-table.tsx
@@ -22,7 +22,7 @@ import {
InfoIcon,
} from "@phosphor-icons/react";
import { PlayerStats } from "../types";
-import Avatar from "@/components/avatar";
+import PlayerAvatar from "@/components/player-avatar";
import { useNavigate } from "@tanstack/react-router";
import { useAllPlayerStats } from "../queries";
@@ -93,7 +93,7 @@ const PlayerListItem = memo(({ stat, onPlayerClick, mmr, onRegisterViewport, onU
}}
>
-
+
diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx
index ab36c20..9dd8749 100644
--- a/src/features/players/components/profile/header.tsx
+++ b/src/features/players/components/profile/header.tsx
@@ -4,7 +4,7 @@ import { Flex, Title, ActionIcon, Stack, Button, Box } from "@mantine/core";
import { PencilIcon, FootballHelmetIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import NameUpdateForm from "./name-form";
-import Avatar from "@/components/avatar";
+import PlayerAvatar from "@/components/player-avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
import PlayerHeadToHeadSheet from "../player-head-to-head-sheet";
@@ -41,7 +41,7 @@ const Header = ({ player }: HeaderProps) => {
<>
-
+
{name}
diff --git a/src/features/players/queries.ts b/src/features/players/queries.ts
index eb61288..53f2b49 100644
--- a/src/features/players/queries.ts
+++ b/src/features/players/queries.ts
@@ -1,5 +1,8 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe, getPlayerStats, getAllPlayerStats, getPlayerMatches, getUnenrolledPlayers, getPlayersActivity } from "./server";
+import { logger } from '@/lib/supertokens';
+
+let queryRefreshRedirect: Promise | null = null;
export const playerKeys = {
auth: ['auth'],
@@ -54,24 +57,45 @@ export const playerQueries = {
export const useMe = () => {
const { queryKey, queryFn } = playerQueries.auth();
- return useServerSuspenseQuery({
- queryKey,
+ return useServerSuspenseQuery({
+ queryKey,
queryFn,
options: {
- staleTime: 0,
- refetchOnMount: true,
+ staleTime: 30 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: true,
retry: (failureCount, error: any) => {
if (error?.response?.status === 401) {
const errorData = error?.response?.data;
- if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
- const currentUrl = window.location.pathname + window.location.search;
- window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
+ if (errorData?.error === 'SESSION_REFRESH_REQUIRED') {
+ logger.warn("Query detected SESSION_REFRESH_REQUIRED");
+
+ if (!queryRefreshRedirect) {
+ const currentUrl = window.location.pathname + window.location.search;
+ logger.info("Query initiating refresh redirect to:", currentUrl);
+
+ queryRefreshRedirect = new Promise((resolve) => {
+ setTimeout(() => {
+ window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
+ resolve();
+ }, 100);
+ });
+
+ queryRefreshRedirect.finally(() => {
+ setTimeout(() => {
+ queryRefreshRedirect = null;
+ }, 1000);
+ });
+ } else {
+ logger.info("Query: refresh redirect already in progress");
+ }
+
return false;
}
}
return failureCount < 3;
- }
- }
+ },
+ },
});
};
diff --git a/src/features/teams/components/team-card.tsx b/src/features/teams/components/team-card.tsx
index 287f39b..31c35cf 100644
--- a/src/features/teams/components/team-card.tsx
+++ b/src/features/teams/components/team-card.tsx
@@ -8,7 +8,7 @@ import {
Title
} from "@mantine/core";
import { useTeam } from "../queries";
-import Avatar from "@/components/avatar";
+import TeamAvatar from "@/components/team-avatar";
import SongSummary from "./team-form/song-summary";
interface TeamCardProps {
@@ -46,11 +46,10 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
>
- {
key={`team-list-${team.id}`}
p="xs"
icon={
-
}
style={{ cursor: "pointer" }}
diff --git a/src/features/tournaments/components/edit-enrolled-teams.tsx b/src/features/tournaments/components/edit-enrolled-teams.tsx
index 02f0d80..1fd105e 100644
--- a/src/features/tournaments/components/edit-enrolled-teams.tsx
+++ b/src/features/tournaments/components/edit-enrolled-teams.tsx
@@ -11,7 +11,7 @@ import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
-import Avatar from "@/components/avatar";
+import TeamAvatar from "@/components/team-avatar";
import { Team, TeamInfo } from "@/features/teams/types";
interface EditEnrolledTeamsProps {
@@ -22,9 +22,10 @@ interface TeamItemProps {
team: TeamInfo;
onUnenroll: (teamId: string) => void;
disabled: boolean;
+ isRegional?: boolean;
}
-const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
+const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
@@ -34,15 +35,11 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
return (
-
@@ -73,6 +70,8 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useUnenrolledTeams(tournamentId);
+ const isRegional = tournament?.regional;
+
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
@@ -107,15 +106,11 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const team = option.data;
return (
-
{team.name}
@@ -174,6 +169,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
team={team}
onUnenroll={handleUnenrollTeam}
disabled={isUnenrolling}
+ isRegional={isRegional}
/>
))}
diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx
index 79902ad..cc06644 100644
--- a/src/features/tournaments/components/manage-tournament.tsx
+++ b/src/features/tournaments/components/manage-tournament.tsx
@@ -87,6 +87,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
start_time: tournament.start_time,
enroll_time: tournament.enroll_time,
end_time: tournament.end_time,
+ regional: tournament.regional,
}}
close={closeEditTournament}
/>
diff --git a/src/features/tournaments/components/seed-tournament.tsx b/src/features/tournaments/components/seed-tournament.tsx
index 8cafb19..f82a89c 100644
--- a/src/features/tournaments/components/seed-tournament.tsx
+++ b/src/features/tournaments/components/seed-tournament.tsx
@@ -13,7 +13,7 @@ import { DotsNineIcon } from "@phosphor-icons/react";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { generateTournamentBracket } from "../../matches/server";
import { TeamInfo } from "@/features/teams/types";
-import Avatar from "@/components/avatar";
+import TeamAvatar from "@/components/team-avatar";
import { useBracketPreview } from "@/features/bracket/queries";
import { BracketData } from "@/features/bracket/types";
import BracketView from "@/features/bracket/components/bracket-view";
@@ -23,11 +23,13 @@ import { tournamentKeys } from "../queries";
interface SeedTournamentProps {
tournamentId: string;
teams: TeamInfo[];
+ isRegional?: boolean;
}
const SeedTournament: React.FC = ({
tournamentId,
teams,
+ isRegional,
}) => {
const [orderedTeams, setOrderedTeams] = useState(teams);
const { data: bracketPreview } = useBracketPreview(teams.length);
@@ -171,15 +173,11 @@ const SeedTournament: React.FC = ({
}}
/>
-
diff --git a/src/features/tournaments/components/tournament-form.tsx b/src/features/tournaments/components/tournament-form.tsx
index 8a7df2d..24d1250 100644
--- a/src/features/tournaments/components/tournament-form.tsx
+++ b/src/features/tournaments/components/tournament-form.tsx
@@ -1,4 +1,4 @@
-import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
+import { FileInput, Stack, TextInput, Textarea, Checkbox } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
@@ -35,6 +35,7 @@ const TournamentForm = ({
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
logo: undefined,
+ regional: initialValues?.regional || false,
},
onSubmitPreventDefault: "always",
validate: {
@@ -150,6 +151,12 @@ const TournamentForm = ({
minRows={3}
/>
+
+
;
diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts
index 9bcb325..72c101d 100644
--- a/src/lib/pocketbase/services/matches.ts
+++ b/src/lib/pocketbase/services/matches.ts
@@ -7,7 +7,7 @@ export function createMatchesService(pb: PocketBase) {
return {
async getMatch(id: string): Promise {
const result = await pb.collection("matches").getOne(id, {
- expand: "tournament, home, away",
+ expand: "tournament, home, away, home.players, away.players",
});
return transformMatch(result);
},
@@ -19,7 +19,7 @@ export function createMatchesService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({
filter: `tournament="${match.tournament.id}" && (home_from_lid = ${match.lid} || away_from_lid = ${match.lid}) && bye = false`,
- expand: "tournament, home, away",
+ expand: "tournament, home, away, home.players, away.players",
});
const winnerMatch = result.find(m => (m.home_from_lid === match.lid && !m.home_from_loser) || (m.away_from_lid === match.lid && !m.away_from_loser));
@@ -50,7 +50,7 @@ export function createMatchesService(pb: PocketBase) {
async updateMatch(id: string, data: Partial): Promise {
logger.info("PocketBase | Updating match", { id, data });
const result = await pb.collection("matches").update(id, data, {
- expand: 'home, away, tournament'
+ expand: 'home, away, tournament, home.players, away.players'
});
return transformMatch(result);
},
diff --git a/src/lib/pocketbase/services/players.ts b/src/lib/pocketbase/services/players.ts
index 2d4c638..e95120e 100644
--- a/src/lib/pocketbase/services/players.ts
+++ b/src/lib/pocketbase/services/players.ts
@@ -126,7 +126,7 @@ export function createPlayersService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
sort: "-created",
- expand: "tournament,home,away",
+ expand: "tournament,home,away,home.players,away.players",
});
return result.map((match) => transformMatch(match));
diff --git a/src/lib/pocketbase/services/teams.ts b/src/lib/pocketbase/services/teams.ts
index e041932..4b1aac5 100644
--- a/src/lib/pocketbase/services/teams.ts
+++ b/src/lib/pocketbase/services/teams.ts
@@ -105,7 +105,7 @@ export function createTeamsService(pb: PocketBase) {
const result = await pb.collection("matches").getFullList({
filter: `(${teamFilter}) && (status = "ended" || status = "started")`,
sort: "-start_time",
- expand: "tournament,home,away",
+ expand: "tournament,home,away,home.players,away.players",
});
return result.map((match) => transformMatch(match));
diff --git a/src/lib/supertokens/client.ts b/src/lib/supertokens/client.ts
index fbed58a..d994557 100644
--- a/src/lib/supertokens/client.ts
+++ b/src/lib/supertokens/client.ts
@@ -4,30 +4,34 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config";
import { logger } from "./";
-let refreshAttemptCount = 0;
+let refreshPromise: Promise | null = null;
export const resetRefreshFlag = () => {
- refreshAttemptCount = 0;
+ refreshPromise = null;
};
-const setupFetchInterceptor = () => {
- if (typeof window === 'undefined') return;
+export const getOrCreateRefreshPromise = (refreshFn: () => Promise): Promise => {
+ if (refreshPromise) {
+ logger.info("Reusing existing refresh promise");
+ return refreshPromise;
+ }
- const originalFetch = window.fetch;
- //@ts-ignore
- window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => {
- const url = typeof resource === 'string' ? resource :
- resource instanceof URL ? resource.toString() : resource.url;
+ logger.info("Creating new refresh promise");
+ refreshPromise = refreshFn()
+ .then((result) => {
+ logger.info("Refresh completed successfully:", result);
+ setTimeout(() => {
+ refreshPromise = null;
+ }, 500);
+ return result;
+ })
+ .catch((error) => {
+ logger.error("Refresh failed:", error);
+ refreshPromise = null;
+ throw error;
+ });
- if (url.includes('/api/auth/session/refresh')) {
- refreshAttemptCount++;
- if (refreshAttemptCount > 1) {
- throw new Error('Duplicate refresh attempt blocked');
- }
- }
-
- return originalFetch.call(window, resource, options);
- };
+ return refreshPromise;
};
export const frontendConfig = () => {
@@ -53,7 +57,6 @@ export function ensureSuperTokensFrontend() {
if (typeof window === "undefined") return;
if (!initialized) {
- setupFetchInterceptor();
SuperTokens.init(frontendConfig());
initialized = true;
logger.info("SuperTokens initialized");
diff --git a/src/lib/tanstack-query/hooks/use-server-mutation.ts b/src/lib/tanstack-query/hooks/use-server-mutation.ts
index 673768c..9484785 100644
--- a/src/lib/tanstack-query/hooks/use-server-mutation.ts
+++ b/src/lib/tanstack-query/hooks/use-server-mutation.ts
@@ -1,8 +1,10 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
+import { logger } from '@/lib/supertokens'
-let isMutationRefreshingSession = false;
+
+let sessionRefreshRedirect: Promise | null = null;
export function useServerMutation(
options: Omit, 'mutationFn'> & {
@@ -39,24 +41,39 @@ export function useServerMutation(
} catch (error: any) {
if (error?.response?.status === 401) {
try {
- const errorData = typeof error.response.data === 'string'
- ? JSON.parse(error.response.data)
+ const errorData = typeof error.response.data === 'string'
+ ? JSON.parse(error.response.data)
: error.response.data;
-
+
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
- if (!isMutationRefreshingSession) {
- isMutationRefreshingSession = true;
+ logger.warn("Mutation detected SESSION_REFRESH_REQUIRED");
+
+ if (!sessionRefreshRedirect) {
const currentUrl = window.location.pathname + window.location.search;
- setTimeout(() => {
- isMutationRefreshingSession = false;
- }, 1000);
- window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
+ logger.info("Mutation initiating refresh redirect to:", currentUrl);
+
+ sessionRefreshRedirect = new Promise((resolve) => {
+ setTimeout(() => {
+ window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
+ resolve();
+ }, 100);
+ });
+
+ sessionRefreshRedirect.finally(() => {
+ setTimeout(() => {
+ sessionRefreshRedirect = null;
+ }, 1000);
+ });
+ } else {
+ logger.info("Mutation: refresh redirect already in progress, waiting...");
+ await sessionRefreshRedirect;
}
+
throw new Error("SESSION_REFRESH_REQUIRED");
}
} catch (parseError) {}
}
-
+
throw error;
}
},
diff --git a/vite.config.ts b/vite.config.ts
index 2cf6de6..cb47ff5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,13 +3,13 @@ import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react';
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
server: {
port: 3000,
allowedHosts: ["dev.flexxon.app", "flexxon.app"]
},
ssr: {
- noExternal: true,
+ noExternal: mode === 'production' ? true : ['facehash'],
},
plugins: [
tsConfigPaths({
@@ -20,4 +20,4 @@ export default defineConfig({
}),
react()
]
-})
+}))