Files
flxn-app/src/app/routes/_authed/admin/tournaments/run.$id.tsx
T
2026-07-22 15:47:34 -07:00

198 lines
6.6 KiB
TypeScript

import { createFileRoute, redirect } from "@tanstack/react-router";
import {
tournamentQueries,
useTournament,
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components";
import { useAuth } from "@/contexts/auth-context";
export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
fullWidth: true,
withPadding: false,
showSpotifyPanel: true,
header: {
withBackButton: true,
title: `Run ${context.tournament.name}`,
},
}),
component: RouteComponent,
pendingComponent: RunTournamentPending,
});
function RunTournamentPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`run-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`run-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
const hasGroupStage = useMemo(() => {
return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
const hasKnockout = useMemo(() => {
return tournament.matches?.some((match) => match.round !== -1) || false;
}, [tournament.matches]);
const knockoutBracketPopulated = useMemo(() => {
return tournament.matches?.some((match) =>
match.round === 0 && match.lid >= 0 && (match.home || match.away)
) || false;
}, [tournament.matches]);
const nextUpMatchId = useMemo(() => {
const ready = (tournament.matches ?? [])
.filter(
(match) =>
match.status === "ready" && match.home && match.away && !match.bye
)
.sort((a, b) => a.order - b.order);
return ready[0]?.id;
}, [tournament.matches]);
const bracket: BracketData = useMemo(() => {
if (!tournament.matches || tournament.matches.length === 0) {
return { winners: [], losers: [] };
}
const winnersMap = new Map<number, Match[]>();
const losersMap = new Map<number, Match[]>();
tournament.matches
.filter((match) => match.round !== -1)
.sort((a, b) => a.lid - b.lid)
.forEach((match) => {
if (!match.is_losers_bracket) {
if (!winnersMap.has(match.round)) {
winnersMap.set(match.round, []);
}
winnersMap.get(match.round)!.push(match);
} else {
if (!losersMap.has(match.round)) {
losersMap.set(match.round, []);
}
losersMap.get(match.round)!.push(match);
}
});
const winners = Array.from(winnersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
const losers = Array.from(losersMap.entries())
.sort(([a], [b]) => a - b)
.map(([, matches]) => matches);
return { winners, losers };
}, [tournament.matches]);
return (
<Container size="md" px={0}>
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
{tournament.matches?.length ? (
hasGroupStage && hasKnockout ? (
<Stack gap="xl">
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches}
showControls
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
nextUpMatchId={nextUpMatchId}
/>
<Divider />
<div>
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
</div>
</Stack>
) : hasGroupStage ? (
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches}
showControls
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
nextUpMatchId={nextUpMatchId}
/>
) : (
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} nextUpMatchId={nextUpMatchId} />
)
) : (
tournament.regional === true ? (
<SetupGroupStage
tournamentId={tournament.id}
teams={tournament.teams || []}
/>
) : (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
)
)}
</Container>
);
}