better brackets, info types
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { MatchCard } from './match-card';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface BracketRoundProps {
|
||||
matches: Match[];
|
||||
roundIndex: number;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
export const BracketRound: React.FC<BracketRoundProps> = ({
|
||||
matches,
|
||||
roundIndex,
|
||||
getParentMatchOrder,
|
||||
onAnnounce,
|
||||
}) => {
|
||||
const isBye = (type: string) => type?.toLowerCase() === 'bye';
|
||||
|
||||
return (
|
||||
<Flex direction="column" key={roundIndex} gap={24} justify="space-around">
|
||||
{matches.map((match, matchIndex) => {
|
||||
if (!match) return null;
|
||||
if (isBye(match.type)) return <></>; // for spacing
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="row"
|
||||
key={matchIndex}
|
||||
align="center"
|
||||
justify="end"
|
||||
gap={8}
|
||||
>
|
||||
<Text c="dimmed" fw="bolder">
|
||||
{match.order}
|
||||
</Text>
|
||||
<MatchCard
|
||||
match={match}
|
||||
getParentMatchOrder={getParentMatchOrder}
|
||||
onAnnounce={onAnnounce}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React, { useCallback } from 'react';
|
||||
import { BracketMaps } from '../utils/bracket-maps';
|
||||
import { BracketRound } from './bracket-round';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface BracketViewProps {
|
||||
bracket: Match[][];
|
||||
bracketMaps: BracketMaps;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
const BracketView: React.FC<BracketViewProps> = ({
|
||||
bracket,
|
||||
bracketMaps,
|
||||
onAnnounce,
|
||||
}) => {
|
||||
|
||||
const getParentMatchOrder = useCallback((parentLid: number): number | string => {
|
||||
const parentMatch = bracketMaps.matchByLid.get(parentLid);
|
||||
if (
|
||||
parentMatch &&
|
||||
parentMatch.order !== null &&
|
||||
parentMatch.order !== undefined
|
||||
) {
|
||||
return parentMatch.order;
|
||||
}
|
||||
return `Match ${parentLid}`;
|
||||
}, [bracketMaps]);
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap={24} justify="left" pos="relative" p="xl">
|
||||
{bracket.map((round, roundIndex) => (
|
||||
<BracketRound
|
||||
key={roundIndex}
|
||||
matches={round}
|
||||
roundIndex={roundIndex}
|
||||
getParentMatchOrder={getParentMatchOrder}
|
||||
onAnnounce={onAnnounce}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BracketView;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ScrollArea, Text } from "@mantine/core";
|
||||
import BracketView from "./bracket-view";
|
||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||
import { BracketMaps } from "../utils/bracket-maps";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
interface BracketProps {
|
||||
winners: Match[][];
|
||||
losers?: Match[][];
|
||||
bracketMaps: BracketMaps | null;
|
||||
}
|
||||
|
||||
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
||||
const height = useAppShellHeight();
|
||||
|
||||
if (!bracketMaps) return <p>Bracket not available.</p>;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h={`calc(${height} - 4rem)`}
|
||||
className="bracket-container"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Winners Bracket
|
||||
</Text>
|
||||
<BracketView bracket={winners} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
{losers && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<BracketView bracket={losers} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bracket;
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ActionIcon, Card, Text } from '@mantine/core';
|
||||
import { PlayIcon } from '@phosphor-icons/react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { MatchSlot } from './match-slot';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface MatchCardProps {
|
||||
match: Match;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
match,
|
||||
getParentMatchOrder,
|
||||
onAnnounce
|
||||
}) => {
|
||||
|
||||
const showAnnounce = useMemo(() =>
|
||||
onAnnounce && match.home.team && match.away.team,
|
||||
[onAnnounce, match.home.team, match.away.team]);
|
||||
|
||||
const handleAnnounce = useCallback(() =>
|
||||
onAnnounce?.(match.home.team, match.away.team), [match.home.team, match.away.team]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
pos="relative"
|
||||
w={200}
|
||||
style={{ overflow: 'visible' }}
|
||||
data-match-lid={match.lid}
|
||||
>
|
||||
<Card.Section withBorder p={0}>
|
||||
<MatchSlot slot={match.home} getParentMatchOrder={getParentMatchOrder} />
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section p={0} mb={-16}>
|
||||
<MatchSlot slot={match.away} getParentMatchOrder={getParentMatchOrder} />
|
||||
</Card.Section>
|
||||
|
||||
{match.reset && (
|
||||
<Text
|
||||
pos="absolute"
|
||||
top={-20}
|
||||
left={8}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw="bold"
|
||||
>
|
||||
* If necessary
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showAnnounce && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
variant="filled"
|
||||
color="green"
|
||||
top={-20}
|
||||
right={-12}
|
||||
onClick={handleAnnounce}
|
||||
bd="none"
|
||||
style={{ boxShadow: 'none' }}
|
||||
size="xs"
|
||||
>
|
||||
<PlayIcon size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { SeedBadge } from "./seed-badge";
|
||||
|
||||
interface MatchSlotProps {
|
||||
slot: any;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
}
|
||||
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
slot,
|
||||
getParentMatchOrder,
|
||||
}) => {
|
||||
const renderSlotContent = () => {
|
||||
if (slot?.seed) {
|
||||
return slot.team ? (
|
||||
<Text size="xs">{slot.team.name}</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Team {slot.seed}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs">
|
||||
{slot.loser ? "Loser" : "Winner"} of Match{" "}
|
||||
{getParentMatchOrder(slot.parent_lid)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs" fs="italic">
|
||||
TBD
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="stretch">
|
||||
{slot?.seed && <SeedBadge seed={slot.seed} />}
|
||||
<div style={{ flex: 1, padding: "4px 8px" }}>{renderSlotContent()}</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
Text,
|
||||
Container,
|
||||
Flex,
|
||||
NumberInput,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { bracketQueries, useBracketPreview } from "../queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
||||
import { BracketData, Match } from "../types";
|
||||
import Bracket from "./bracket";
|
||||
import "./styles.module.css";
|
||||
|
||||
interface PreviewTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const PreviewBracket: React.FC = () => {
|
||||
const [teamCount, setTeamCount] = useState(20);
|
||||
const { data, isLoading, error } = useBracketPreview(teamCount);
|
||||
|
||||
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setTeams(
|
||||
Array.from({ length: teamCount }, (_, i) => ({
|
||||
id: `team-${i + 1}`,
|
||||
name: `Team ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
}, [teamCount]);
|
||||
|
||||
const [seededWinnersBracket, setSeededWinnersBracket] = useState<Match[][]>(
|
||||
[]
|
||||
);
|
||||
const [seededLosersBracket, setSeededLosersBracket] = useState<Match[][]>([]);
|
||||
const [bracketMaps, setBracketMaps] = useState<BracketMaps | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || teams.length === 0) return;
|
||||
|
||||
const maps = createBracketMaps(data);
|
||||
setBracketMaps(maps);
|
||||
|
||||
const mapBracket = (bracket: Match[][]) => {
|
||||
return bracket.map((round) =>
|
||||
round.map((match) => {
|
||||
const mappedMatch = { ...match };
|
||||
|
||||
if (match.home?.seed && match.home.seed > 0) {
|
||||
const teamIndex = match.home.seed - 1;
|
||||
if (teams[teamIndex]) {
|
||||
mappedMatch.home = {
|
||||
...match.home,
|
||||
team: teams[teamIndex],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (match.away?.seed && match.away.seed > 0) {
|
||||
const teamIndex = match.away.seed - 1;
|
||||
if (teams[teamIndex]) {
|
||||
mappedMatch.away = {
|
||||
...match.away,
|
||||
team: teams[teamIndex],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return mappedMatch;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const bracketData = data as BracketData;
|
||||
setSeededWinnersBracket(mapBracket(bracketData.winners));
|
||||
setSeededLosersBracket(mapBracket(bracketData.losers));
|
||||
}, [teams, data]);
|
||||
|
||||
if (error) return <p>Error loading bracket</p>;
|
||||
|
||||
return (
|
||||
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
||||
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
||||
<Group gap="sm" mx="auto">
|
||||
<Text size="sm" c="dimmed">
|
||||
Teams:
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={teamCount}
|
||||
onChange={(value) => setTeamCount(Number(value) || 12)}
|
||||
min={12}
|
||||
max={20}
|
||||
size="sm"
|
||||
w={80}
|
||||
allowDecimal={false}
|
||||
clampBehavior="strict"
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
<Flex w="100%" gap={24}>
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" h="20vh" w="100%">
|
||||
<Loader size="xl" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Bracket
|
||||
winners={seededWinnersBracket}
|
||||
losers={seededLosersBracket}
|
||||
bracketMaps={bracketMaps}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
|
||||
interface SeedBadgeProps {
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const SeedBadge: React.FC<SeedBadgeProps> = ({ seed }) => {
|
||||
return (
|
||||
<Text
|
||||
size="xs"
|
||||
fw="bold"
|
||||
py="4"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{
|
||||
width: "32px",
|
||||
textAlign: "center",
|
||||
color: "var(--mantine-color-text)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderTopLeftRadius: "var(--mantine-radius-default)",
|
||||
borderBottomLeftRadius: "var(--mantine-radius-default)",
|
||||
}}
|
||||
>
|
||||
{seed}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Flex, Text, Select, Card } from "@mantine/core";
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SeedListProps {
|
||||
teams: Team[];
|
||||
onSeedChange: (currentIndex: number, newIndex: number) => void;
|
||||
}
|
||||
|
||||
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||
const seedOptions = teams.map((_, index) => ({
|
||||
value: index.toString(),
|
||||
label: `Seed ${index + 1}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={8}>
|
||||
{teams.map((team, index) => (
|
||||
<Card key={team.id} withBorder p="xs">
|
||||
<Flex align="center" gap="xs" justify="space-between">
|
||||
<Flex align="center" gap="xs">
|
||||
<Select
|
||||
value={index.toString()}
|
||||
data={seedOptions}
|
||||
onChange={(value) => {
|
||||
if (value !== null) {
|
||||
const newIndex = parseInt(value);
|
||||
if (newIndex !== index) {
|
||||
onSeedChange(index, newIndex);
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
w={100}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.bracket-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bracket-container {
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { previewBracket } from "./server";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { BracketData } from "./types";
|
||||
|
||||
const bracketKeys = {
|
||||
preview: (teams: number) => ["bracket", "preview", teams] as const,
|
||||
};
|
||||
|
||||
export const bracketQueries = {
|
||||
preview: (teams: number) => ({
|
||||
queryKey: bracketKeys.preview(teams),
|
||||
queryFn: () => previewBracket({ data: teams }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useBracketPreview = (teams: number) =>
|
||||
useServerSuspenseQuery<BracketData>(bracketQueries.preview(teams));
|
||||
@@ -1,21 +0,0 @@
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import brackets from "./utils";
|
||||
import { BracketData } from "./types";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
const logger = new Logger("Bracket Generation");
|
||||
|
||||
export const previewBracket = createServerFn()
|
||||
.validator(z.number())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teams }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Generating bracket", teams);
|
||||
if (!Object.keys(brackets).includes(teams.toString()))
|
||||
throw Error("Bracket not available");
|
||||
return brackets[teams as keyof typeof brackets] as BracketData;
|
||||
})
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
/*export interface Slot {
|
||||
seed?: number;
|
||||
team?: any;
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
lid: number;
|
||||
round: number;
|
||||
order: number | null;
|
||||
type: string;
|
||||
home: Slot;
|
||||
away: Slot;
|
||||
reset?: boolean;
|
||||
}*/
|
||||
import { Match } from "../matches/types";
|
||||
|
||||
export interface BracketData {
|
||||
winners: Match[][];
|
||||
losers: Match[][];
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { BracketData, Match } from "../types";
|
||||
|
||||
export interface BracketMaps {
|
||||
matchByLid: Map<number, Match>;
|
||||
matchByOrder: Map<number, Match>;
|
||||
allMatches: Match[];
|
||||
}
|
||||
|
||||
export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
||||
const matchByLid = new Map<number, Match>();
|
||||
const matchByOrder = new Map<number, Match>();
|
||||
const allMatches: Match[] = [];
|
||||
|
||||
[...bracketData.winners, ...bracketData.losers].forEach((round) => {
|
||||
round.forEach((match) => {
|
||||
matchByLid.set(match.lid, match);
|
||||
|
||||
if (match.order !== null && match.order !== undefined) {
|
||||
matchByOrder.set(match.order, match);
|
||||
}
|
||||
|
||||
allMatches.push(match);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
matchByLid,
|
||||
matchByOrder,
|
||||
allMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMatchByLid(
|
||||
maps: BracketMaps,
|
||||
lid: number
|
||||
): Match | undefined {
|
||||
return maps.matchByLid.get(lid);
|
||||
}
|
||||
|
||||
export function getMatchByOrder(
|
||||
maps: BracketMaps,
|
||||
order: number
|
||||
): Match | undefined {
|
||||
return maps.matchByOrder.get(order);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
||||
*/
|
||||
import b12 from "../../../../scripts/brackets/12.json";
|
||||
import b13 from "../../../../scripts/brackets/13.json";
|
||||
import b14 from "../../../../scripts/brackets/14.json";
|
||||
import b15 from "../../../../scripts/brackets/15.json";
|
||||
import b16 from "../../../../scripts/brackets/16.json";
|
||||
import b17 from "../../../../scripts/brackets/17.json";
|
||||
import b18 from "../../../../scripts/brackets/18.json";
|
||||
import b19 from "../../../../scripts/brackets/19.json";
|
||||
import b20 from "../../../../scripts/brackets/20.json";
|
||||
|
||||
export default {
|
||||
12: b12,
|
||||
13: b13,
|
||||
14: b14,
|
||||
15: b15,
|
||||
16: b16,
|
||||
17: b17,
|
||||
18: b18,
|
||||
19: b19,
|
||||
20: b20,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useBracketPreview } from "../queries";
|
||||
import BracketView from "./bracket-view";
|
||||
import { useBracketPreview } from "../../bracket/queries";
|
||||
import BracketView from "../../bracket/components/bracket-view";
|
||||
|
||||
interface BracketPreviewProps {
|
||||
n: number;
|
||||
136
src/features/matches/server.ts
Normal file
136
src/features/matches/server.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
|
||||
const orderedTeamsSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
orderedTeamIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.validator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating tournament bracket', { tournamentId, teamCount: orderedTeamIds.length });
|
||||
|
||||
// Get tournament with teams
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
if (!tournament) {
|
||||
throw new Error('Tournament not found');
|
||||
}
|
||||
|
||||
// Check if tournament already has matches
|
||||
if (tournament.matches && tournament.matches.length > 0) {
|
||||
throw new Error('Tournament already has matches generated');
|
||||
}
|
||||
|
||||
// Get bracket template based on team count
|
||||
const teamCount = orderedTeamIds.length;
|
||||
if (!Object.keys(brackets).includes(teamCount.toString())) {
|
||||
throw new Error(`Bracket not available for ${teamCount} teams`);
|
||||
}
|
||||
|
||||
const bracketTemplate = brackets[teamCount as keyof typeof brackets] as any;
|
||||
|
||||
// Create seed to team mapping (index + 1 = seed)
|
||||
const seedToTeamId = new Map<number, string>();
|
||||
orderedTeamIds.forEach((teamId, index) => {
|
||||
seedToTeamId.set(index + 1, teamId);
|
||||
});
|
||||
|
||||
// Convert bracket template to match records
|
||||
const matchInputs: MatchInput[] = [];
|
||||
|
||||
// Process winners bracket
|
||||
bracketTemplate.winners.forEach((round: any[]) => {
|
||||
round.forEach((match: any) => {
|
||||
const matchInput: MatchInput = {
|
||||
lid: match.lid,
|
||||
round: match.round,
|
||||
order: match.order || 0,
|
||||
reset: match.reset || false,
|
||||
bye: match.bye || false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: match.home_from_lid,
|
||||
away_from_lid: match.away_from_lid,
|
||||
home_from_loser: match.home_from_loser || false,
|
||||
away_from_loser: match.away_from_loser || false,
|
||||
is_losers_bracket: false,
|
||||
tournament: tournamentId,
|
||||
};
|
||||
|
||||
// Assign teams based on seeds
|
||||
if (match.home_seed) {
|
||||
const teamId = seedToTeamId.get(match.home_seed);
|
||||
if (teamId) {
|
||||
matchInput.home = teamId;
|
||||
matchInput.home_seed = match.home_seed;
|
||||
}
|
||||
}
|
||||
|
||||
if (match.away_seed) {
|
||||
const teamId = seedToTeamId.get(match.away_seed);
|
||||
if (teamId) {
|
||||
matchInput.away = teamId;
|
||||
matchInput.away_seed = match.away_seed;
|
||||
}
|
||||
}
|
||||
|
||||
matchInputs.push(matchInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Process losers bracket
|
||||
bracketTemplate.losers.forEach((round: any[]) => {
|
||||
round.forEach((match: any) => {
|
||||
const matchInput: MatchInput = {
|
||||
lid: match.lid,
|
||||
round: match.round,
|
||||
order: match.order || 0,
|
||||
reset: match.reset || false,
|
||||
bye: match.bye || false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: match.home_from_lid,
|
||||
away_from_lid: match.away_from_lid,
|
||||
home_from_loser: match.home_from_loser || false,
|
||||
away_from_loser: match.away_from_loser || false,
|
||||
is_losers_bracket: true,
|
||||
tournament: tournamentId,
|
||||
};
|
||||
|
||||
// Losers bracket matches don't start with teams
|
||||
// Teams come from winners bracket losses
|
||||
|
||||
matchInputs.push(matchInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Create all matches
|
||||
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
||||
|
||||
// Update tournament to include all match IDs in the matches relation
|
||||
const matchIds = createdMatches.map(match => match.id);
|
||||
await pbAdmin.updateTournamentMatches(tournamentId, matchIds);
|
||||
|
||||
logger.info('Tournament bracket generated', {
|
||||
tournamentId,
|
||||
matchCount: createdMatches.length
|
||||
});
|
||||
|
||||
return {
|
||||
tournament,
|
||||
matchCount: createdMatches.length,
|
||||
matches: createdMatches,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { TeamInfo } from "../teams/types";
|
||||
import { TournamentInfo } from "../tournaments/types";
|
||||
|
||||
/**
|
||||
* class TMatchSlot(BaseModel):
|
||||
@@ -50,9 +52,9 @@ export interface Match {
|
||||
home_from_loser: boolean;
|
||||
away_from_loser: boolean;
|
||||
is_losers_bracket: boolean;
|
||||
tournament: string;
|
||||
home: string;
|
||||
away: string;
|
||||
tournament: TournamentInfo;
|
||||
home?: TeamInfo;
|
||||
away?: TeamInfo;
|
||||
created: string;
|
||||
updated: string;
|
||||
home_seed?: number;
|
||||
@@ -78,7 +80,8 @@ export const matchInputSchema = z.object({
|
||||
tournament: z.string().min(1),
|
||||
home: z.string().min(1).optional(),
|
||||
away: z.string().min(1).optional(),
|
||||
seed: z.number().int().min(1).optional(),
|
||||
home_seed: z.number().int().min(1).optional(),
|
||||
away_seed: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import Header from "./header";
|
||||
import { Player } from "@/features/players/types";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import { usePlayer } from "../../queries";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -13,24 +14,26 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <Text p="md">Stats/Badges will go here</Text>
|
||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Text p="md">Matches feed will go here</Text>
|
||||
content: <Text p="md">Matches feed will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <Text p="md">Teams will go here</Text>
|
||||
}
|
||||
content: <TeamList teams={player.teams || []} />,
|
||||
},
|
||||
];
|
||||
|
||||
return <>
|
||||
<Header player={player} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<Header player={player} />
|
||||
<Box m="sm" mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface PlayerInfo {
|
||||
id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
auth_id?: string;
|
||||
@@ -8,7 +14,7 @@ export interface Player {
|
||||
last_name?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
teams?: Team[];
|
||||
teams?: TeamInfo[];
|
||||
}
|
||||
|
||||
export const playerInputSchema = z.object({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface TeamListItemProps {
|
||||
team: Team;
|
||||
team: TeamInfo;
|
||||
}
|
||||
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
const playerNames = useMemo(
|
||||
@@ -29,7 +29,7 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
});
|
||||
|
||||
interface TeamListProps {
|
||||
teams: Team[];
|
||||
teams: TeamInfo[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Player } from "@/features/players/types";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { z } from "zod";
|
||||
import { Tournament } from "../tournaments/types";
|
||||
import { TournamentInfo } from "../tournaments/types";
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
@@ -18,8 +18,8 @@ export interface Team {
|
||||
song_image_url: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
players: Player[];
|
||||
tournaments: Tournament[];
|
||||
players: PlayerInfo[];
|
||||
tournaments: TournamentInfo[];
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
@@ -27,6 +27,7 @@ export interface TeamInfo {
|
||||
name: string;
|
||||
primary_color: string;
|
||||
accent_color: string;
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
export const teamInputSchema = z
|
||||
|
||||
@@ -12,14 +12,14 @@ 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 { Team } from "@/features/teams/types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
|
||||
interface EditEnrolledTeamsProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
interface TeamItemProps {
|
||||
team: Team;
|
||||
team: TeamInfo;
|
||||
onUnenroll: (teamId: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledTeams.map((team: Team) => (
|
||||
{enrolledTeams.map((team: TeamInfo) => (
|
||||
<TeamItem
|
||||
key={team.id}
|
||||
team={team}
|
||||
|
||||
205
src/features/tournaments/components/seed-tournament.tsx
Normal file
205
src/features/tournaments/components/seed-tournament.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Button,
|
||||
NumberInput,
|
||||
LoadingOverlay,
|
||||
} from "@mantine/core";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
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 { useBracketPreview } from "@/features/bracket/queries";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
|
||||
interface SeedTournamentProps {
|
||||
tournamentId: string;
|
||||
teams: TeamInfo[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const SeedTournament: React.FC<SeedTournamentProps> = ({
|
||||
tournamentId,
|
||||
teams,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
|
||||
const { data: bracketPreview } = useBracketPreview(teams.length);
|
||||
|
||||
const bracket: BracketData = useMemo(
|
||||
() => ({
|
||||
winners: bracketPreview.winners.map((round) =>
|
||||
round.map((match) => ({
|
||||
...match,
|
||||
away:
|
||||
match.away_seed !== undefined
|
||||
? orderedTeams[match.away_seed - 1]
|
||||
: undefined,
|
||||
home:
|
||||
match.home_seed !== undefined
|
||||
? orderedTeams[match.home_seed - 1]
|
||||
: undefined,
|
||||
}))
|
||||
),
|
||||
losers: bracketPreview.losers
|
||||
}),
|
||||
[bracketPreview, orderedTeams]
|
||||
);
|
||||
|
||||
const generateBracket = useServerMutation({
|
||||
mutationFn: generateTournamentBracket,
|
||||
successMessage: "Tournament bracket generated successfully!",
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDragEnd = (result: any) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(orderedTeams);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setOrderedTeams(items);
|
||||
};
|
||||
|
||||
const handleSeedChange = (teamId: string, newSeed: number) => {
|
||||
if (newSeed < 1 || newSeed > orderedTeams.length) return;
|
||||
|
||||
const currentIndex = orderedTeams.findIndex((t) => t.id === teamId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const targetIndex = newSeed - 1;
|
||||
const items = Array.from(orderedTeams);
|
||||
const [movedTeam] = items.splice(currentIndex, 1);
|
||||
items.splice(targetIndex, 0, movedTeam);
|
||||
|
||||
setOrderedTeams(items);
|
||||
};
|
||||
|
||||
const handleGenerateBracket = () => {
|
||||
const orderedTeamIds = orderedTeams.map((team) => team.id);
|
||||
|
||||
generateBracket.mutate({
|
||||
data: {
|
||||
tournamentId,
|
||||
orderedTeamIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start' }}>
|
||||
<Stack gap="lg" style={{ flexShrink: 0 }}>
|
||||
<Stack gap={0} pos="relative" w={400}>
|
||||
<LoadingOverlay visible={generateBracket.isPending} />
|
||||
|
||||
<Group gap="xs" p="md" pb="sm" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
Team Seeding
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ml="auto">
|
||||
{orderedTeams.length} teams
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
w={400}
|
||||
onClick={handleGenerateBracket}
|
||||
loading={generateBracket.isPending}
|
||||
disabled={orderedTeams.length === 0}
|
||||
>
|
||||
Confirm Seeding
|
||||
</Button>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="teams">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{orderedTeams.map((team, index) => (
|
||||
<Draggable
|
||||
key={team.id}
|
||||
draggableId={team.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
borderBottom:
|
||||
"1px solid var(--mantine-color-dimmed)",
|
||||
}}
|
||||
>
|
||||
<Group align="center" gap="sm" p="sm" px="md">
|
||||
<ActionIcon
|
||||
{...provided.dragHandleProps}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
>
|
||||
<DotsNineIcon size={14} />
|
||||
</ActionIcon>
|
||||
|
||||
<NumberInput
|
||||
value={index + 1}
|
||||
onChange={(value) =>
|
||||
handleSeedChange(team.id, Number(value) || 1)
|
||||
}
|
||||
min={1}
|
||||
max={orderedTeams.length}
|
||||
size="xs"
|
||||
w={50}
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
height: 28,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Avatar size={24} radius="sm" name={team.name} />
|
||||
|
||||
<Text fw={500} size="sm" style={{ flex: 1 }}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Stack>
|
||||
<Button
|
||||
size="sm"
|
||||
w={400}
|
||||
onClick={handleGenerateBracket}
|
||||
loading={generateBracket.isPending}
|
||||
disabled={orderedTeams.length === 0}
|
||||
>
|
||||
Confirm Seeding
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<BracketView bracket={bracket} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeedTournament;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { List, ListItem, Skeleton, Text } from "@mantine/core";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Tournament } from "../types";
|
||||
import { TournamentInfo } from "../types";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface TournamentListProps {
|
||||
tournaments: Tournament[];
|
||||
tournaments: TournamentInfo[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface TournamentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,7 +23,8 @@ export interface Tournament {
|
||||
end_time?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
teams?: Team[];
|
||||
teams?: TeamInfo[];
|
||||
matches?: Match[];
|
||||
}
|
||||
|
||||
export const tournamentInputSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user