From 937758bd49a5ba81380752f44ee221c576af20b5 Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 14:31:55 -0600 Subject: [PATCH 1/5] facehash avatars --- bun.lock | 3 + package.json | 1 + src/components/player-avatar.tsx | 85 +++++++++++++++++++ .../components/league-head-to-head.tsx | 12 +-- .../players/components/player-list.tsx | 4 +- .../players/components/player-stats-table.tsx | 4 +- .../players/components/profile/header.tsx | 4 +- vite.config.ts | 6 +- 8 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 src/components/player-avatar.tsx 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/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/features/players/components/league-head-to-head.tsx b/src/features/players/components/league-head-to-head.tsx index f8b6b49..449bace 100644 --- a/src/features/players/components/league-head-to-head.tsx +++ b/src/features/players/components/league-head-to-head.tsx @@ -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(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/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() ] -}) +})) From 5dd41d802223d3b25a83d79aed980dc3ea8d43fe Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 22:20:00 -0600 Subject: [PATCH 2/5] fix scroll bug --- src/features/core/components/pullable.tsx | 58 ++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) 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} From 63853f22de1b7105eeab65b77937b1d3eca5be53 Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 23:36:04 -0600 Subject: [PATCH 3/5] regional teams --- .../_authed/admin/tournaments/run.$id.tsx | 1 + src/components/team-avatar.tsx | 139 ++++++++++++++++++ .../matches/components/match-card.tsx | 56 ++----- src/features/teams/components/team-card.tsx | 7 +- src/features/teams/components/team-list.tsx | 11 +- .../components/edit-enrolled-teams.tsx | 28 ++-- .../components/seed-tournament.tsx | 14 +- src/lib/pocketbase/services/matches.ts | 6 +- src/lib/pocketbase/services/players.ts | 2 +- src/lib/pocketbase/services/teams.ts | 2 +- 10 files changed, 181 insertions(+), 85 deletions(-) create mode 100644 src/components/team-avatar.tsx 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/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/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 && ( - - - - )} { > - { 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/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/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)); From 9ed054e5d06a3879fc0a214e341db44eb45dc17a Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 23:44:07 -0600 Subject: [PATCH 4/5] regionals on tournament form --- .../tournaments/components/manage-tournament.tsx | 1 + src/features/tournaments/components/tournament-form.tsx | 9 ++++++++- src/features/tournaments/types.ts | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) 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/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} /> + + ; From 236fcda67173467c2d07db30f839e2f5f707f7ff Mon Sep 17 00:00:00 2001 From: yohlo Date: Mon, 9 Feb 2026 23:53:54 -0600 Subject: [PATCH 5/5] session fixes --- src/app/routes/__root.tsx | 2 + src/app/routes/refresh-session.tsx | 25 +++++--- src/components/session-monitor.tsx | 60 +++++++++++++++++++ src/features/players/queries.ts | 42 ++++++++++--- src/lib/supertokens/client.ts | 41 +++++++------ .../hooks/use-server-mutation.ts | 39 ++++++++---- 6 files changed, 162 insertions(+), 47 deletions(-) create mode 100644 src/components/session-monitor.tsx 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/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/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/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/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; } },