social overhaul
This commit is contained in:
@@ -15,12 +15,14 @@ import AnimatedScore from "@/features/matches/components/animated-score";
|
||||
interface GroupMatchCardProps {
|
||||
match: Match;
|
||||
showControls?: boolean;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
|
||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, nextUpMatchId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const editSheet = useSheet();
|
||||
|
||||
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
|
||||
const isReady = match.status === "ready";
|
||||
const isStarted = match.status === "started";
|
||||
const isEnded = match.status === "ended";
|
||||
@@ -73,7 +75,17 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex direction="row" align="stretch">
|
||||
<Flex
|
||||
direction="row"
|
||||
align="stretch"
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
boxShadow: isNextUp
|
||||
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
|
||||
: undefined,
|
||||
transition: "box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
|
||||
}}
|
||||
>
|
||||
<Indicator
|
||||
inline
|
||||
processing={isStarted}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutatio
|
||||
import { populateKnockoutBracket } from "@/features/matches/server";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "../queries";
|
||||
import { computeGroupStageOrder } from "../utils/match-queue";
|
||||
|
||||
interface GroupStageViewProps {
|
||||
groups: Group[];
|
||||
@@ -18,6 +19,7 @@ interface GroupStageViewProps {
|
||||
hasKnockoutBracket?: boolean;
|
||||
isRegional?: boolean;
|
||||
groupConfig?: GroupConfig;
|
||||
nextUpMatchId?: string;
|
||||
}
|
||||
|
||||
interface TeamStanding {
|
||||
@@ -39,6 +41,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
hasKnockoutBracket,
|
||||
isRegional,
|
||||
groupConfig,
|
||||
nextUpMatchId,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
@@ -64,35 +67,22 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
populateKnockoutMutation.mutate({ data: { tournamentId } });
|
||||
};
|
||||
|
||||
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||
if (matches.length <= 1) return matches;
|
||||
const globalOrderRank = useMemo(() => {
|
||||
const groupMatches = matches.filter((match) => match.round === -1 && match.group);
|
||||
const order = computeGroupStageOrder(
|
||||
groupMatches.map((m) => ({
|
||||
groupId: m.group ?? "",
|
||||
teamA: m.home?.id ?? "",
|
||||
teamB: m.away?.id ?? "",
|
||||
})),
|
||||
);
|
||||
|
||||
const ordered: Match[] = [];
|
||||
const remaining = [...matches];
|
||||
|
||||
ordered.push(remaining.shift()!);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastMatch = ordered[ordered.length - 1];
|
||||
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
|
||||
|
||||
let bestMatchIndex = remaining.findIndex((match) => {
|
||||
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
|
||||
for (const teamId of currentTeams) {
|
||||
if (lastTeams.has(teamId)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (bestMatchIndex === -1) {
|
||||
bestMatchIndex = 0;
|
||||
}
|
||||
|
||||
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
const rank = new Map<string, number>();
|
||||
order.forEach((matchIdx, position) => {
|
||||
rank.set(groupMatches[matchIdx].id, position);
|
||||
});
|
||||
return rank;
|
||||
}, [matches]);
|
||||
|
||||
const matchesByGroup = useMemo(() => {
|
||||
const map = new Map<string, Match[]>();
|
||||
@@ -107,11 +97,14 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
});
|
||||
|
||||
map.forEach((groupMatches, groupId) => {
|
||||
map.set(groupId, orderMatchesWithSpacing(groupMatches));
|
||||
const sorted = [...groupMatches].sort(
|
||||
(a, b) => (globalOrderRank.get(a.id) ?? 0) - (globalOrderRank.get(b.id) ?? 0),
|
||||
);
|
||||
map.set(groupId, sorted);
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [matches]);
|
||||
}, [matches, globalOrderRank]);
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return [...groups].sort((a, b) => a.order - b.order);
|
||||
@@ -461,6 +454,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
key={match.id}
|
||||
match={match}
|
||||
showControls={showControls}
|
||||
nextUpMatchId={nextUpMatchId}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
|
||||
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
|
||||
import { Tournament } from "../../types";
|
||||
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
@@ -11,7 +11,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack px="sm" align="center" gap={0}>
|
||||
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
src={
|
||||
@@ -25,27 +25,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
: undefined
|
||||
}
|
||||
radius="md"
|
||||
size={250}
|
||||
size={64}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
<TrophyIcon size={24} />
|
||||
</GlitchAvatar>
|
||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<MapPinIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{tournament.location}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
<CalendarIcon size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text size="sm" c="dimmed" lineClamp={1}>
|
||||
{tournamentStart.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
@@ -58,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,50 +1,83 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import carouselClasses from "./carousel.module.css";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||
import { predictionQueries } from "@/features/predictions/queries";
|
||||
import { useServerQuery } from "@/lib/tanstack-query/hooks";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import Header from "./header";
|
||||
import { Podium } from "../podium";
|
||||
import SectionHeading from "./sections/section-heading";
|
||||
import MatchCarousel from "./sections/match-carousel";
|
||||
import ScoreReportCta from "./sections/score-report-cta";
|
||||
import RecentResults from "./sections/recent-results";
|
||||
import NavGrid from "./sections/nav-grid";
|
||||
|
||||
const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
|
||||
|
||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { roles } = useAuth();
|
||||
const { user, roles } = useAuth();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
|
||||
const startedMatches = useMemo(() =>
|
||||
tournament.matches?.filter(match => match.status === "started") || [],
|
||||
[tournament.matches]
|
||||
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||
|
||||
const liveMatches = useMemo(
|
||||
() => matches.filter((m) => m.status === "started"),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const upcomingMatches = useMemo(
|
||||
() => matches.filter((m) => m.status === "ready" && m.home && m.away),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const endedMatches = useMemo(
|
||||
() =>
|
||||
matches
|
||||
.filter((m) => m.status === "ended")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.end_time).getTime() - new Date(a.end_time).getTime()
|
||||
),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const myLiveMatches = useMemo(
|
||||
() =>
|
||||
liveMatches.filter(
|
||||
(m) =>
|
||||
!!user?.id &&
|
||||
(teamPlayerIds(m.home).includes(user.id) ||
|
||||
teamPlayerIds(m.away).includes(user.id))
|
||||
),
|
||||
[liveMatches, user?.id]
|
||||
);
|
||||
|
||||
const isTournamentOver = useMemo(() => {
|
||||
const matches = tournament.matches || [];
|
||||
if (matches.length === 0) return false;
|
||||
|
||||
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
|
||||
const nonByeMatches = matches.filter(
|
||||
(match) => !(match.status === "tbd" && match.bye === true)
|
||||
);
|
||||
if (nonByeMatches.length === 0) return false;
|
||||
|
||||
const finalsMatch = nonByeMatches.reduce((highest, current) =>
|
||||
(!highest || current.lid > highest.lid) ? current : highest
|
||||
!highest || current.lid > highest.lid ? current : highest
|
||||
);
|
||||
|
||||
return finalsMatch?.status === 'ended';
|
||||
}, [tournament.matches]);
|
||||
return finalsMatch?.status === "ended";
|
||||
}, [matches]);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
const hasGroupStage = useMemo(
|
||||
() => matches.some((match) => match.round === -1),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const isPredictable = useMemo(
|
||||
() => isTournamentPredictable(tournament),
|
||||
@@ -52,8 +85,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
);
|
||||
|
||||
const predictionsLocked = useMemo(
|
||||
() => isPredictionLocked(tournament.matches || []),
|
||||
[tournament.matches]
|
||||
() => isPredictionLocked(matches),
|
||||
[matches]
|
||||
);
|
||||
|
||||
const { data: myPrediction } = useServerQuery({
|
||||
@@ -62,48 +95,42 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
});
|
||||
const hasSubmitted = !!myPrediction?.prediction;
|
||||
|
||||
const hasNoMatchContent =
|
||||
!isTournamentOver &&
|
||||
liveMatches.length === 0 &&
|
||||
upcomingMatches.length === 0 &&
|
||||
endedMatches.length === 0;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
|
||||
{startedMatches.length > 0 ? (
|
||||
<Box>
|
||||
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap">
|
||||
<Indicator
|
||||
size={8}
|
||||
color="red"
|
||||
processing
|
||||
position="middle-start"
|
||||
offset={0}
|
||||
/>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
|
||||
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Carousel
|
||||
slideSize="90%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
withIndicators={startedMatches.length > 1}
|
||||
classNames={{
|
||||
indicators: carouselClasses.indicators,
|
||||
indicator: carouselClasses.indicator,
|
||||
}}
|
||||
>
|
||||
{startedMatches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
<ScoreReportCta matches={myLiveMatches} />
|
||||
|
||||
{isTournamentOver && (
|
||||
<Box px="lg" w="100%">
|
||||
<Podium tournament={tournament} />
|
||||
</Box>
|
||||
) : isTournamentOver ? (
|
||||
<Box px="lg" w="100%">
|
||||
<Podium tournament={tournament} />
|
||||
</Box>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{liveMatches.length > 0 && (
|
||||
<Box>
|
||||
<SectionHeading label="Live Matches" live count={liveMatches.length} />
|
||||
<MatchCarousel matches={liveMatches} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{upcomingMatches.length > 0 && (
|
||||
<Box>
|
||||
<SectionHeading label="Upcoming" count={upcomingMatches.length} />
|
||||
{/* SIDE BETS SLOT — see docs/side-bets.md */}
|
||||
<MatchCarousel matches={upcomingMatches} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<RecentResults matches={endedMatches} tournamentId={tournament.id} />
|
||||
|
||||
{hasNoMatchContent && (
|
||||
<Card withBorder radius="lg" p="xl" mx="md">
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
@@ -117,45 +144,18 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Divider />
|
||||
{isAdmin && (
|
||||
<ListLink
|
||||
label={`Manage ${tournament.name}`}
|
||||
to={`/admin/tournaments/${tournament.id}`}
|
||||
Icon={UsersIcon}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
<Divider mb="sm" />
|
||||
<NavGrid
|
||||
tournament={tournament}
|
||||
isAdmin={isAdmin}
|
||||
hasGroupStage={hasGroupStage}
|
||||
isPredictable={isPredictable}
|
||||
predictionsLocked={predictionsLocked}
|
||||
hasSubmitted={hasSubmitted}
|
||||
/>
|
||||
{isPredictable && !predictionsLocked && (
|
||||
<ListLink
|
||||
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
|
||||
to={`/tournaments/${tournament.id}/predictions/make`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||
<ListLink
|
||||
label={`View Predictions`}
|
||||
to={`/tournaments/${tournament.id}/predictions`}
|
||||
Icon={WizardOrbIcon}
|
||||
/>
|
||||
)}
|
||||
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
|
||||
<RulesListButton tournamentId={tournament.id} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartedTournament;
|
||||
export default StartedTournament;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Box } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import carouselClasses from "../carousel.module.css";
|
||||
|
||||
interface MatchCarouselProps {
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
const MatchCarousel = ({ matches }: MatchCarouselProps) => {
|
||||
if (matches.length === 1) {
|
||||
return (
|
||||
<Box px="md">
|
||||
<MatchCard match={matches[0]} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
slideSize="90%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
withIndicators={matches.length > 1}
|
||||
classNames={{
|
||||
indicators: carouselClasses.indicators,
|
||||
indicator: carouselClasses.indicator,
|
||||
}}
|
||||
>
|
||||
{matches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box
|
||||
pl={index === 0 ? "md" : undefined}
|
||||
pr={index === matches.length - 1 ? "md" : undefined}
|
||||
>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
))}
|
||||
</Carousel>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchCarousel;
|
||||
@@ -0,0 +1,38 @@
|
||||
.tile {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 8px;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
background-color: var(--mantine-color-body);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tile:hover:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.tile:active:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.tile {
|
||||
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.tile:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { Icon } from "@phosphor-icons/react";
|
||||
import {
|
||||
ListDashes,
|
||||
ListIcon,
|
||||
TreeStructureIcon,
|
||||
UsersIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import WizardOrbIcon from "@/components/wizard-orb-icon";
|
||||
import { Tournament } from "../../../types";
|
||||
import classes from "./nav-grid.module.css";
|
||||
|
||||
const RulesContent = lazy(
|
||||
() => import("../../upcoming-tournament/rules-content")
|
||||
);
|
||||
|
||||
interface NavTileProps {
|
||||
label: string;
|
||||
Icon: Icon;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A single square-ish nav tile with icon-over-label and press feedback. */
|
||||
const NavTile = ({ label, Icon, onClick }: NavTileProps) => (
|
||||
<UnstyledButton className={classes.tile} onClick={onClick}>
|
||||
<span className={classes.icon}>
|
||||
<Icon size={22} weight="bold" />
|
||||
</span>
|
||||
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
|
||||
{label}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
interface NavGridProps {
|
||||
tournament: Tournament;
|
||||
isAdmin: boolean;
|
||||
hasGroupStage: boolean;
|
||||
isPredictable: boolean;
|
||||
predictionsLocked: boolean;
|
||||
hasSubmitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consolidates the old stack of full-width ListLinks into a compact icon grid
|
||||
* so it no longer dominates the page. Every original destination stays
|
||||
* reachable with identical conditional visibility. Teams and Rules open sheets;
|
||||
* everything else navigates.
|
||||
*/
|
||||
const NavGrid = ({
|
||||
tournament,
|
||||
isAdmin,
|
||||
hasGroupStage,
|
||||
isPredictable,
|
||||
predictionsLocked,
|
||||
hasSubmitted,
|
||||
}: NavGridProps) => {
|
||||
const navigate = useNavigate();
|
||||
const teamsSheet = useSheet();
|
||||
const rulesSheet = useSheet();
|
||||
|
||||
return (
|
||||
<Box px="md">
|
||||
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||
{isAdmin && (
|
||||
<NavTile
|
||||
label="Manage"
|
||||
Icon={UsersIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/admin/tournaments/${tournament.id}` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<NavTile
|
||||
label="Groups"
|
||||
Icon={ListDashes}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/groups` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NavTile
|
||||
label="Bracket"
|
||||
Icon={TreeStructureIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/bracket` })
|
||||
}
|
||||
/>
|
||||
{isPredictable && !predictionsLocked && (
|
||||
<NavTile
|
||||
label={hasSubmitted ? "Edit Prediction" : "Make Prediction"}
|
||||
Icon={WizardOrbIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isPredictable && (predictionsLocked || hasSubmitted) && (
|
||||
<NavTile
|
||||
label="Predictions"
|
||||
Icon={WizardOrbIcon}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournament.id}/predictions` })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NavTile
|
||||
label={`Teams${
|
||||
tournament.teams ? ` (${tournament.teams.length})` : ""
|
||||
}`}
|
||||
Icon={UsersIcon}
|
||||
onClick={teamsSheet.open}
|
||||
/>
|
||||
<NavTile label="Rules" Icon={ListIcon} onClick={rulesSheet.open} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Sheet title="Enrolled Teams" {...teamsSheet.props}>
|
||||
<TeamList
|
||||
teams={tournament.teams || []}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
</Sheet>
|
||||
|
||||
<Sheet title="Tournament Rules" {...rulesSheet.props}>
|
||||
<Stack gap="xs">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="100%"
|
||||
style={{ minHeight: "25vh" }}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<RulesContent content={tournament.rules || ""} />
|
||||
</Suspense>
|
||||
<Button variant="subtle" c="red" onClick={rulesSheet.close}>
|
||||
Close
|
||||
</Button>
|
||||
</Stack>
|
||||
</Sheet>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavGrid;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Anchor, Box, Group, Stack } from "@mantine/core";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import SectionHeading from "./section-heading";
|
||||
|
||||
interface RecentResultsProps {
|
||||
/** Ended matches, already sorted most-recent-first. */
|
||||
matches: Match[];
|
||||
tournamentId: string;
|
||||
/** How many to show inline before offering "View all results". */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recently finished matches, newest on top, so people can look back and react
|
||||
* (each MatchCard carries its own reaction bar). Caps the inline list and links
|
||||
* to the full bracket when there are more results than fit.
|
||||
*/
|
||||
const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const shown = matches.slice(0, max);
|
||||
const hasMore = matches.length > max;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionHeading
|
||||
label="Recently finished"
|
||||
action={
|
||||
hasMore ? (
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
fw={600}
|
||||
onClick={() =>
|
||||
navigate({ to: `/tournaments/${tournamentId}/bracket` })
|
||||
}
|
||||
>
|
||||
<Group gap={2} align="center" wrap="nowrap">
|
||||
View all results
|
||||
<CaretRightIcon size={12} weight="bold" />
|
||||
</Group>
|
||||
</Anchor>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Stack gap="sm" px="md">
|
||||
{shown.map((match) => (
|
||||
<MatchCard key={match.id} match={match} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentResults;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { MatchReport } from "@/features/bracket/components/match-report";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import SectionHeading from "./section-heading";
|
||||
|
||||
interface ScoreReportCtaProps {
|
||||
/** Started matches the current user is playing in. */
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
const teamName = (t: Match["home"]): string =>
|
||||
t && typeof t !== "string" ? t.name : "TBD";
|
||||
|
||||
/**
|
||||
* Top-of-home call-to-action for a participant whose match is live. Composes
|
||||
* the existing <MatchReport> so reporting/confirming a score is one tap from
|
||||
* the home screen instead of buried in the bracket. Renders nothing when the
|
||||
* user isn't playing in a started match.
|
||||
*/
|
||||
const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionHeading
|
||||
label="Your match is live"
|
||||
live
|
||||
count={matches.length}
|
||||
/>
|
||||
<Stack gap="sm" px="md">
|
||||
{matches.map((match) => (
|
||||
<Paper key={match.id} withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{match.home && typeof match.home !== "string" && (
|
||||
<TeamAvatar
|
||||
team={match.home}
|
||||
size={28}
|
||||
radius="sm"
|
||||
isRegional={match.tournament.regional === true}
|
||||
/>
|
||||
)}
|
||||
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
|
||||
{teamName(match.home)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" fw={600} style={{ flexShrink: 0 }}>
|
||||
vs
|
||||
</Text>
|
||||
<Group
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
justify="flex-end"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
ta="right"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{teamName(match.away)}
|
||||
</Text>
|
||||
{match.away && typeof match.away !== "string" && (
|
||||
<TeamAvatar
|
||||
team={match.away}
|
||||
size={28}
|
||||
radius="sm"
|
||||
isRegional={match.tournament.regional === true}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<MatchReport match={match} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScoreReportCta;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Group, Indicator, Text } from "@mantine/core";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
label: string;
|
||||
/** Show a pulsing red "live" dot before the label. */
|
||||
live?: boolean;
|
||||
/** Appended as "· N" after the label when greater than 1. */
|
||||
count?: number;
|
||||
/** Optional right-aligned action (e.g. a "View all" link). */
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, uppercase section label used across the started-tournament home
|
||||
* surfaces. Mirrors the original "Live Matches" heading treatment.
|
||||
*/
|
||||
const SectionHeading = ({ label, live, count, action }: SectionHeadingProps) => {
|
||||
return (
|
||||
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap" justify="space-between">
|
||||
<Group gap={10} align="center" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{live && (
|
||||
<Indicator
|
||||
size={8}
|
||||
color="red"
|
||||
processing
|
||||
position="middle-start"
|
||||
offset={0}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em" lineClamp={1}>
|
||||
{label}
|
||||
{count && count > 1 ? ` · ${count}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{action}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionHeading;
|
||||
@@ -1,70 +1,78 @@
|
||||
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
|
||||
import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{ minWidth, flex: "0 0 auto" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={14} width="30%" />
|
||||
<Skeleton height={20} width={40} radius="xl" />
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
</Box>
|
||||
</Group>
|
||||
<Group>
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const StartedTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header skeleton */}
|
||||
<Stack px="md" align="center" gap="xs">
|
||||
<Skeleton height={268} width={268} radius="lg" />
|
||||
<Skeleton height={16} width="55%" />
|
||||
</Stack>
|
||||
{/* Compact header skeleton */}
|
||||
<Group px="md" gap="md" wrap="nowrap" align="center">
|
||||
<Skeleton height={64} width={64} radius="md" />
|
||||
<Stack gap={8} style={{ flex: 1 }}>
|
||||
<Skeleton height={14} width="55%" />
|
||||
<Skeleton height={14} width="40%" />
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Match carousel skeleton */}
|
||||
{/* Live carousel skeleton */}
|
||||
<Box>
|
||||
<Group gap="xs" px="xl">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ minWidth: "95%", flex: "0 0 auto" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Match header */}
|
||||
<Group justify="space-between">
|
||||
<Skeleton height={14} width="30%" />
|
||||
<Skeleton height={20} width={60} radius="xl" />
|
||||
</Group>
|
||||
|
||||
{/* Teams */}
|
||||
<Stack gap="sm">
|
||||
<Group>
|
||||
<Skeleton height={32} width={32} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
</Box>
|
||||
</Group>
|
||||
<Center>
|
||||
<Skeleton height={14} width={20} />
|
||||
</Center>
|
||||
<Group>
|
||||
<Skeleton height={32} width={32} radius="sm" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={24} width={30} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
<Skeleton height={12} width={90} ml="md" mb={10} />
|
||||
<Group gap="xs" pl="md" wrap="nowrap" style={{ overflow: "hidden" }}>
|
||||
<MatchCardSkeleton minWidth="88%" />
|
||||
<MatchCardSkeleton minWidth="88%" />
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* Actions section skeleton */}
|
||||
{/* Recently finished skeleton */}
|
||||
<Box>
|
||||
<Divider />
|
||||
<Stack gap={0}>
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={12} width={120} ml="md" mb={10} />
|
||||
<Stack gap="sm" px="md">
|
||||
<MatchCardSkeleton minWidth="100%" />
|
||||
<MatchCardSkeleton minWidth="100%" />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Nav grid skeleton */}
|
||||
<Box px="md">
|
||||
<Divider mb="sm" />
|
||||
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} height={72} radius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartedTournamentSkeleton;
|
||||
export default StartedTournamentSkeleton;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||
import { orderGroupStageMatches } from "./utils/match-queue";
|
||||
|
||||
export const listTournaments = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
@@ -69,12 +70,6 @@ export const enrollTeam = createServerFn()
|
||||
const team = await pbAdmin.getTeam(teamId);
|
||||
if (!team) { throw new Error('Team not found'); }
|
||||
|
||||
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
|
||||
|
||||
//if (!isPlayerOnTeam && !isAdmin) {
|
||||
// throw new Error('You do not have permission to enroll this team');
|
||||
//}
|
||||
|
||||
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
|
||||
for (const player of team.players || []) {
|
||||
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
|
||||
@@ -816,7 +811,8 @@ export const generateGroupStage = createServerFn()
|
||||
groupStageMatches.push({
|
||||
lid: -1,
|
||||
round: -1,
|
||||
order: groupStageMatches.length + 1,
|
||||
order: 0,
|
||||
|
||||
reset: false,
|
||||
bye: false,
|
||||
home: teamIds[i],
|
||||
@@ -838,6 +834,14 @@ export const generateGroupStage = createServerFn()
|
||||
}
|
||||
}
|
||||
|
||||
const orderedGroupMatches = orderGroupStageMatches(
|
||||
groupStageMatches,
|
||||
(m) => ({ groupId: m.group, teamA: m.home, teamB: m.away })
|
||||
);
|
||||
orderedGroupMatches.forEach((match, index) => {
|
||||
match.order = index + 1;
|
||||
});
|
||||
|
||||
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||
|
||||
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
export interface OrderableMatch {
|
||||
groupId: string;
|
||||
teamA: string;
|
||||
teamB: string;
|
||||
}
|
||||
|
||||
const BYE = "__BYE__";
|
||||
|
||||
function pairKey(a: string, b: string): string {
|
||||
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||
}
|
||||
|
||||
function circleMethodRoundIndex(teams: string[]): Map<string, number> {
|
||||
const sorted = [...teams].sort();
|
||||
const arr = [...sorted];
|
||||
if (arr.length % 2 === 1) arr.push(BYE);
|
||||
|
||||
const n = arr.length;
|
||||
const result = new Map<string, number>();
|
||||
if (n < 2) return result;
|
||||
|
||||
const rounds = n - 1;
|
||||
const half = n / 2;
|
||||
let list = [...arr];
|
||||
|
||||
for (let r = 0; r < rounds; r++) {
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = list[i];
|
||||
const b = list[n - 1 - i];
|
||||
if (a !== BYE && b !== BYE) {
|
||||
result.set(pairKey(a, b), r);
|
||||
}
|
||||
}
|
||||
list = [list[0], list[n - 1], ...list.slice(1, n - 1)];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface AnnotatedMatch {
|
||||
idx: number;
|
||||
groupId: string;
|
||||
teamA: string;
|
||||
teamB: string;
|
||||
roundIndex: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function isBetter(a: number[], aKey: string, b: number[], bKey: string): boolean {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return a[i] > b[i];
|
||||
}
|
||||
return aKey < bKey;
|
||||
}
|
||||
|
||||
export function computeGroupStageOrder(matches: OrderableMatch[]): number[] {
|
||||
const n = matches.length;
|
||||
if (n <= 1) return matches.map((_, i) => i);
|
||||
|
||||
const groupTeams = new Map<string, Set<string>>();
|
||||
for (const m of matches) {
|
||||
let set = groupTeams.get(m.groupId);
|
||||
if (!set) {
|
||||
set = new Set<string>();
|
||||
groupTeams.set(m.groupId, set);
|
||||
}
|
||||
set.add(m.teamA);
|
||||
set.add(m.teamB);
|
||||
}
|
||||
|
||||
const roundIndexByGroup = new Map<string, Map<string, number>>();
|
||||
for (const [groupId, teams] of groupTeams) {
|
||||
roundIndexByGroup.set(groupId, circleMethodRoundIndex([...teams]));
|
||||
}
|
||||
|
||||
const items: AnnotatedMatch[] = matches.map((m, idx) => {
|
||||
const round = roundIndexByGroup.get(m.groupId)?.get(pairKey(m.teamA, m.teamB));
|
||||
return {
|
||||
idx,
|
||||
groupId: m.groupId,
|
||||
teamA: m.teamA,
|
||||
teamB: m.teamB,
|
||||
roundIndex: round ?? Number.MAX_SAFE_INTEGER,
|
||||
key: `${m.groupId}|${pairKey(m.teamA, m.teamB)}`,
|
||||
};
|
||||
});
|
||||
|
||||
const remaining = new Set<number>(items.map((it) => it.idx));
|
||||
const itemByIdx = new Map<number, AnnotatedMatch>(items.map((it) => [it.idx, it]));
|
||||
|
||||
const order: number[] = [];
|
||||
const lastPlayed = new Map<string, number>();
|
||||
const NEVER = -1 - n;
|
||||
let prevTeams = new Set<string>();
|
||||
let prevGroup: string | null = null;
|
||||
|
||||
for (let pos = 0; pos < n; pos++) {
|
||||
let best: AnnotatedMatch | null = null;
|
||||
let bestScore: number[] | null = null;
|
||||
|
||||
for (const idx of remaining) {
|
||||
const it = itemByIdx.get(idx)!;
|
||||
|
||||
const adjacent = prevTeams.has(it.teamA) || prevTeams.has(it.teamB) ? 1 : 0;
|
||||
const gapA = pos - (lastPlayed.get(it.teamA) ?? NEVER);
|
||||
const gapB = pos - (lastPlayed.get(it.teamB) ?? NEVER);
|
||||
const gap = Math.min(gapA, gapB);
|
||||
const sameGroup = prevGroup !== null && it.groupId === prevGroup ? 1 : 0;
|
||||
const score = [-adjacent, gap, -sameGroup, -it.roundIndex];
|
||||
|
||||
if (best === null || isBetter(score, it.key, bestScore!, best.key)) {
|
||||
best = it;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
order.push(best!.idx);
|
||||
remaining.delete(best!.idx);
|
||||
lastPlayed.set(best!.teamA, pos);
|
||||
lastPlayed.set(best!.teamB, pos);
|
||||
prevTeams = new Set([best!.teamA, best!.teamB]);
|
||||
prevGroup = best!.groupId;
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export function orderGroupStageMatches<T>(
|
||||
items: T[],
|
||||
accessor: (item: T) => OrderableMatch,
|
||||
): T[] {
|
||||
const orderable = items.map(accessor);
|
||||
const order = computeGroupStageOrder(orderable);
|
||||
return order.map((idx) => items[idx]);
|
||||
}
|
||||
Reference in New Issue
Block a user