Merge pull request 'development' (#4) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s

Reviewed-on: #4
This commit is contained in:
2026-02-10 14:03:25 -06:00
28 changed files with 514 additions and 149 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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 (
<RootDocument>
<Providers>
<SessionMonitor />
<Outlet />
</Providers>
</RootDocument>

View File

@@ -86,6 +86,7 @@ function RouteComponent() {
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
)}
</Container>

View File

@@ -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';
}
}

View File

@@ -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 (
<Paper
p={size / 20}
radius="100%"
withBorder
style={{
cursor: !disableFullscreen ? 'pointer' : 'default',
transition: 'transform 0.15s ease',
...style,
}}
onMouseEnter={(e) => {
if (!disableFullscreen) {
e.currentTarget.style.transform = 'scale(1.02)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<div
style={{
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderRadius: '100%',
}}
>
<Facehash
name={name}
size={size}
variant="solid"
colors={colors}
intensity3d="dramatic"
enableBlink
style={{ borderRadius: '100%', overflow: 'hidden', color: 'black' }}
/>
</div>
</Paper>
);
};
export default PlayerAvatar;

View File

@@ -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<number>(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;
}

View File

@@ -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 (
<Box
style={{
position: "relative",
width: size,
height: size,
display: "flex",
alignItems: "center",
justifyContent: "center",
...style,
}}
>
<AvatarGroup spacing={size * -0.25}>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[0].first_name} ${team.players[0].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 1.1,
left: "45%",
transform: "translateX(-50%)",
color: "gold",
rotate: "-5deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
<Box style={{ position: "relative" }}>
<PlayerAvatar
name={`${team.players[1].first_name} ${team.players[1].last_name}`}
size={playerSize}
disableFullscreen={disableFullscreen}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.95,
left: "65%",
transform: "translateX(-50%)",
color: "gold",
rotate: "10deg",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
</AvatarGroup>
</Box>
);
}
const crownSize = Math.max(14, size * 0.4);
return (
<Box style={{ position: "relative", ...style }}>
<Avatar
name={team.name}
size={size}
radius={radius}
withBorder={withBorder}
disableFullscreen={disableFullscreen}
contain={contain}
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
/>
{winner && (
<Box
style={{
position: "absolute",
top: -crownSize * 0.6,
left: -crownSize * 0.25,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={crownSize} weight="fill" />
</Box>
)}
</Box>
);
};
export default TeamAvatar;

View File

@@ -85,6 +85,55 @@ const Pullable: React.FC<PullableProps> = ({ 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<PullableProps> = ({ children, scrollPosition, onScrollP
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
styles={{
viewport: {
'& > *': {
pointerEvents: 'auto'
}
}
}}
>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box', pointerEvents: 'auto' }}>
{children}
</Box>
</ScrollArea>

View File

@@ -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) => {
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
style={{ cursor: "pointer" }}
onClick={handleHomeTeamClick}
>
<Avatar
<TeamAvatar
team={match.home!}
size={40}
name={match.home?.name!}
radius="sm"
src={
match.home?.logo
? `/api/files/teams/${match.home?.id}/${match.home?.logo}`
: undefined
}
winner={isHomeWin}
isRegional={match.tournament.regional}
/>
{isHomeWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.home?.name!}
@@ -175,32 +159,16 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
<Group justify="space-between" align="center">
<Group gap="sm" style={{ flex: 1 }}>
<Box
style={{ position: "relative", cursor: "pointer" }}
style={{ cursor: "pointer" }}
onClick={handleAwayTeamClick}
>
<Avatar
<TeamAvatar
team={match.away!}
size={40}
name={match.away?.name!}
radius="sm"
src={
match.away?.logo
? `/api/files/teams/${match.away?.id}/${match.away?.logo}`
: undefined
}
winner={isAwayWin}
isRegional={match.tournament.regional}
/>
{isAwayWin && (
<Box
style={{
position: "absolute",
top: -10,
left: -4,
transform: "rotate(-25deg)",
color: "gold",
}}
>
<CrownIcon size={16} weight="fill" />
</Box>
)}
</Box>
<Tooltip
label={match.away?.name}

View File

@@ -5,7 +5,7 @@ import { useAllPlayerStats } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
import Avatar from "@/components/avatar";
import PlayerAvatar from "@/components/player-avatar";
const LeagueHeadToHead = () => {
const [player1Id, setPlayer1Id] = useState<string | null>(null);
@@ -89,7 +89,7 @@ const LeagueHeadToHead = () => {
{player1Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<Avatar name={player1Name} size={36} />
<PlayerAvatar name={player1Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player1Name}
</Text>
@@ -110,7 +110,7 @@ const LeagueHeadToHead = () => {
</>
) : (
<Stack gap={4} align="center">
<Avatar size={36} />
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 1
</Text>
@@ -145,7 +145,7 @@ const LeagueHeadToHead = () => {
{player2Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<Avatar name={player2Name} size={36} />
<PlayerAvatar name={player2Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player2Name}
</Text>
@@ -166,7 +166,7 @@ const LeagueHeadToHead = () => {
</>
) : (
<Stack gap={4} align="center">
<Avatar size={36} />
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
Player 2
</Text>
@@ -241,7 +241,7 @@ const LeagueHeadToHead = () => {
},
}}
>
<Avatar name={player.player_name} size={44} />
<PlayerAvatar name={player.player_name} size={44} disableFullscreen />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{player.player_name}

View File

@@ -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) => (
<ListItem key={player.id}
py='xs'
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
icon={<PlayerAvatar size={40} name={`${player.first_name} ${player.last_name}`} disableFullscreen />}
style={{ cursor: 'pointer' }}
onClick={() => handleClick(player.id)}
>

View File

@@ -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
}}
>
<Group p={0} gap="sm" align="center" w="100%" wrap="nowrap" style={{ overflow: 'hidden' }}>
<Avatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} />
<PlayerAvatar name={stat.player_name} size={40} style={{ flexShrink: 0 }} disableFullscreen />
<Stack gap={2} style={{ flexGrow: 1, overflow: 'hidden', minWidth: 0 }}>
<Group gap='xs'>
<Text size="sm" fw={600}>

View File

@@ -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) => {
<>
<Stack gap="sm" align="center" pt="md">
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={100} />
<PlayerAvatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={nameSheet.open}>

View File

@@ -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<void> | null = null;
export const playerKeys = {
auth: ['auth'],
@@ -58,20 +61,41 @@ export const useMe = () => {
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<void>((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;
}
}
},
},
});
};

View File

@@ -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) => {
>
<Stack gap={2}>
<Group gap="md" align="center" p="xs">
<Avatar
name={team.name}
<TeamAvatar
team={team}
size={40}
radius="md"
src={team.logo ? `/api/files/teams/${team.id}/${team.logo}` : undefined}
style={{
backgroundColor: team.primary_color || undefined,
color: team.accent_color || undefined,

View File

@@ -7,7 +7,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import Avatar from "@/components/avatar";
import TeamAvatar from "@/components/team-avatar";
import { TeamInfo } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
@@ -88,15 +88,10 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
key={`team-list-${team.id}`}
p="xs"
icon={
<Avatar
<TeamAvatar
team={team}
radius="sm"
size={40}
name={`${team.name}`}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/>
}
style={{ cursor: "pointer" }}

View File

@@ -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 (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar
<TeamAvatar
team={team}
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
@@ -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 (
<Group py="xs" px="sm" gap="sm" align="center">
<Avatar
<TeamAvatar
team={team as any}
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Text fw={500} truncate>
{team.name}
@@ -174,6 +169,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
team={team}
onUnenroll={handleUnenrollTeam}
disabled={isUnenrolling}
isRegional={isRegional}
/>
))}
</Stack>

View File

@@ -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}
/>

View File

@@ -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<SeedTournamentProps> = ({
tournamentId,
teams,
isRegional,
}) => {
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
const { data: bracketPreview } = useBracketPreview(teams.length);
@@ -171,15 +173,11 @@ const SeedTournament: React.FC<SeedTournamentProps> = ({
}}
/>
<Avatar
<TeamAvatar
team={team}
size={24}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
isRegional={isRegional}
/>
<Text fw={500} size="sm" style={{ flex: 1 }}>

View File

@@ -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}
/>
<Checkbox
label="Regional Tournament"
key={form.key("regional")}
{...form.getInputProps("regional", { type: "checkbox" })}
/>
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"

View File

@@ -63,6 +63,7 @@ export const tournamentInputSchema = z.object({
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
regional: z.boolean().optional().default(false),
});
export type TournamentInput = z.infer<typeof tournamentInputSchema>;

View File

@@ -7,7 +7,7 @@ export function createMatchesService(pb: PocketBase) {
return {
async getMatch(id: string): Promise<Match | null> {
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<MatchInput>): Promise<Match> {
logger.info("PocketBase | Updating match", { id, data });
const result = await pb.collection("matches").update<Match>(id, data, {
expand: 'home, away, tournament'
expand: 'home, away, tournament, home.players, away.players'
});
return transformMatch(result);
},

View File

@@ -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));

View File

@@ -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));

View File

@@ -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<boolean> | null = null;
export const resetRefreshFlag = () => {
refreshAttemptCount = 0;
refreshPromise = null;
};
const setupFetchInterceptor = () => {
if (typeof window === 'undefined') return;
export const getOrCreateRefreshPromise = (refreshFn: () => Promise<boolean>): Promise<boolean> => {
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");

View File

@@ -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<void> | null = null;
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
@@ -44,14 +46,29 @@ export function useServerMutation<TData, TVariables = unknown>(
: 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<void>((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) {}

View File

@@ -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()
]
})
}))