mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-27 01:37:19 -05:00
712 lines
22 KiB
TypeScript
712 lines
22 KiB
TypeScript
import type {
|
|
ActionFunction,
|
|
LinksFunction,
|
|
LoaderArgs,
|
|
SerializeFrom,
|
|
} from "@remix-run/node";
|
|
import {
|
|
Form,
|
|
Link,
|
|
useLoaderData,
|
|
useNavigate,
|
|
useOutletContext,
|
|
useRevalidator,
|
|
} from "@remix-run/react";
|
|
import * as React from "react";
|
|
import bracketViewerStyles from "../brackets-viewer.css";
|
|
import bracketStyles from "../tournament-bracket.css";
|
|
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
|
|
import { Alert } from "~/components/Alert";
|
|
import { SubmitButton } from "~/components/SubmitButton";
|
|
import { getTournamentManager } from "../core/brackets-manager";
|
|
import hasTournamentStarted from "../../tournament/queries/hasTournamentStarted.server";
|
|
import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server";
|
|
import { notFoundIfFalsy, validate } from "~/utils/remix";
|
|
import {
|
|
SENDOU_INK_BASE_URL,
|
|
tournamentBracketsSubscribePage,
|
|
tournamentJoinPage,
|
|
tournamentMatchPage,
|
|
tournamentTeamPage,
|
|
userPage,
|
|
} from "~/utils/urls";
|
|
import type { TournamentLoaderData } from "../../tournament/routes/to.$id";
|
|
import { resolveBestOfs } from "../core/bestOf.server";
|
|
import { findAllMatchesByTournamentId } from "../queries/findAllMatchesByTournamentId.server";
|
|
import { setBestOf } from "../queries/setBestOf.server";
|
|
import { canAdminTournament } from "~/permissions";
|
|
import { requireUser, useUser } from "~/modules/auth";
|
|
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
|
|
import {
|
|
bracketSubscriptionKey,
|
|
everyMatchIsOver,
|
|
fillWithNullTillPowerOfTwo,
|
|
resolveTournamentStageName,
|
|
resolveTournamentStageSettings,
|
|
resolveTournamentStageType,
|
|
} from "../tournament-bracket-utils";
|
|
import { sql } from "~/db/sql";
|
|
import { useEventSource } from "remix-utils";
|
|
import { Status } from "~/db/types";
|
|
import { checkInHasStarted, teamHasCheckedIn } from "~/features/tournament";
|
|
import clsx from "clsx";
|
|
import { Button, LinkButton } from "~/components/Button";
|
|
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
|
import { bestOfsByTournamentId } from "../queries/bestOfsByTournamentId.server";
|
|
import type { FinalStanding } from "../core/finalStandings.server";
|
|
import { finalStandings } from "../core/finalStandings.server";
|
|
import { Placement } from "~/components/Placement";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Divider } from "~/components/Divider";
|
|
import { removeDuplicates } from "~/utils/arrays";
|
|
import { Flag } from "~/components/Flag";
|
|
import { databaseTimestampToDate } from "~/utils/dates";
|
|
import { Popover } from "~/components/Popover";
|
|
import { useCopyToClipboard } from "react-use";
|
|
import { useTranslation } from "~/hooks/useTranslation";
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [
|
|
{
|
|
rel: "stylesheet",
|
|
href: "https://cdn.jsdelivr.net/npm/brackets-viewer@1.5.1/dist/brackets-viewer.min.css",
|
|
},
|
|
{
|
|
rel: "stylesheet",
|
|
href: bracketViewerStyles,
|
|
},
|
|
{
|
|
rel: "stylesheet",
|
|
href: bracketStyles,
|
|
},
|
|
];
|
|
};
|
|
|
|
export const action: ActionFunction = async ({ params, request }) => {
|
|
const user = await requireUser(request);
|
|
const manager = getTournamentManager("SQL");
|
|
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
|
|
const hasStarted = hasTournamentStarted(tournamentId);
|
|
|
|
validate(canAdminTournament({ user, event: tournament }));
|
|
validate(!hasStarted);
|
|
|
|
let teams = findTeamsByTournamentId(tournamentId);
|
|
if (checkInHasStarted(tournament)) {
|
|
teams = teams.filter(teamHasCheckedIn);
|
|
}
|
|
|
|
validate(teams.length >= 2, "Not enough teams registered");
|
|
|
|
sql.transaction(() => {
|
|
manager.create({
|
|
tournamentId,
|
|
name: resolveTournamentStageName(tournament.format),
|
|
type: resolveTournamentStageType(tournament.format),
|
|
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
|
|
settings: resolveTournamentStageSettings(tournament.format),
|
|
});
|
|
|
|
const bestOfs = resolveBestOfs(findAllMatchesByTournamentId(tournamentId));
|
|
for (const [bestOf, id] of bestOfs) {
|
|
setBestOf({ bestOf, id });
|
|
}
|
|
})();
|
|
|
|
return null;
|
|
};
|
|
|
|
export type TournamentBracketLoaderData = SerializeFrom<typeof loader>;
|
|
|
|
export const loader = ({ params }: LoaderArgs) => {
|
|
const tournamentId = tournamentIdFromParams(params);
|
|
|
|
const hasStarted = hasTournamentStarted(tournamentId);
|
|
const manager = getTournamentManager(hasStarted ? "SQL" : "IN_MEMORY");
|
|
|
|
if (hasStarted) {
|
|
const bracket = manager.get.tournamentData(tournamentId);
|
|
const _everyMatchIsOver = everyMatchIsOver(bracket);
|
|
return {
|
|
hasStarted: true,
|
|
enoughTeams: true,
|
|
bracket,
|
|
roundBestOfs: bestOfsByTournamentId(tournamentId),
|
|
everyMatchIsOver: _everyMatchIsOver,
|
|
finalStandings: _everyMatchIsOver
|
|
? finalStandings({ manager, tournamentId })
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
|
|
|
|
let teams = findTeamsByTournamentId(tournamentId);
|
|
if (checkInHasStarted(tournament)) {
|
|
teams = teams.filter(teamHasCheckedIn);
|
|
}
|
|
|
|
const enoughTeams = teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START;
|
|
if (enoughTeams) {
|
|
manager.create({
|
|
tournamentId,
|
|
name: resolveTournamentStageName(tournament.format),
|
|
type: resolveTournamentStageType(tournament.format),
|
|
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
|
|
settings: resolveTournamentStageSettings(tournament.format),
|
|
});
|
|
}
|
|
|
|
// TODO: use get.stageData
|
|
const data = manager.get.tournamentData(tournamentId);
|
|
|
|
return {
|
|
bracket: data,
|
|
hasStarted,
|
|
enoughTeams,
|
|
everyMatchIsOver: false,
|
|
roundBestOfs: null,
|
|
finalStandings: null,
|
|
};
|
|
};
|
|
|
|
export default function TournamentBracketsPage() {
|
|
const visibility = useVisibilityChange();
|
|
const { revalidate } = useRevalidator();
|
|
const user = useUser();
|
|
const data = useLoaderData<typeof loader>();
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
const navigate = useNavigate();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
|
|
React.useEffect(() => {
|
|
if (!data.enoughTeams) return;
|
|
|
|
// matches aren't generated before tournament starts
|
|
if (data.hasStarted) {
|
|
// @ts-expect-error - brackets-viewer is not typed
|
|
window.bracketsViewer.onMatchClicked = (match) => {
|
|
// can't view match page of a bye
|
|
if (match.opponent1 === null || match.opponent2 === null) {
|
|
return;
|
|
}
|
|
navigate(
|
|
tournamentMatchPage({
|
|
eventId: parentRouteData.event.id,
|
|
matchId: match.id,
|
|
})
|
|
);
|
|
};
|
|
}
|
|
|
|
// @ts-expect-error - brackets-viewer is not typed
|
|
window.bracketsViewer.render(
|
|
{
|
|
stages: data.bracket.stage,
|
|
matches: data.bracket.match,
|
|
matchGames: data.bracket.match_game,
|
|
participants: data.bracket.participant,
|
|
},
|
|
{
|
|
customRoundName: (info: any) => {
|
|
if (info.groupType === "final-group" && info.roundNumber === 1) {
|
|
return "Grand Finals";
|
|
}
|
|
if (info.groupType === "final-group" && info.roundNumber === 2) {
|
|
return "Bracket Reset";
|
|
}
|
|
|
|
return undefined;
|
|
},
|
|
separatedChildCountLabel: true,
|
|
}
|
|
);
|
|
|
|
// my beautiful hack to show seeds
|
|
// clean up probably not needed as it's not harmful to append more than one
|
|
const cssRulesToAppend = parentRouteData.teams.map((team, i) => {
|
|
const participantId = data.hasStarted ? team.id : i;
|
|
return /* css */ `
|
|
[data-participant-id="${participantId}"] {
|
|
--seed: "${i + 1} ";
|
|
--space-after-seed: ${i < 9 ? "6px" : "0px"};
|
|
}
|
|
`;
|
|
});
|
|
if (parentRouteData.teamMemberOfName) {
|
|
cssRulesToAppend.push(/* css */ `
|
|
[title="${parentRouteData.teamMemberOfName}"] {
|
|
--team-text-color: var(--theme-secondary);
|
|
}
|
|
`);
|
|
}
|
|
if (data.roundBestOfs) {
|
|
for (const { bestOf, roundId } of data.roundBestOfs) {
|
|
cssRulesToAppend.push(/* css */ `
|
|
[data-round-id="${roundId}"] {
|
|
--best-of-text: "Bo${bestOf}";
|
|
}
|
|
`);
|
|
}
|
|
}
|
|
appendStyleTagToHead(cssRulesToAppend.join("\n"));
|
|
|
|
const element = ref.current;
|
|
return () => {
|
|
if (!element) return;
|
|
|
|
element.innerHTML = "";
|
|
};
|
|
}, [data, navigate, parentRouteData]);
|
|
|
|
React.useEffect(() => {
|
|
if (visibility !== "visible" || data.everyMatchIsOver) return;
|
|
|
|
revalidate();
|
|
}, [visibility, revalidate, data.everyMatchIsOver]);
|
|
|
|
const myTeam = parentRouteData.teams.find((team) =>
|
|
team.members.some((m) => m.userId === user?.id)
|
|
);
|
|
|
|
const adminCanStart = () => {
|
|
// for testing, is always possible to start in development
|
|
if (process.env.NODE_ENV === "development") return true;
|
|
|
|
return (
|
|
databaseTimestampToDate(parentRouteData.event.startTime).getTime() <
|
|
Date.now()
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{visibility !== "hidden" && !data.everyMatchIsOver ? (
|
|
<AutoRefresher />
|
|
) : null}
|
|
{!data.hasStarted && data.enoughTeams ? (
|
|
<Form method="post" className="stack items-center">
|
|
{!canAdminTournament({ user, event: parentRouteData.event }) ? (
|
|
<Alert
|
|
variation="INFO"
|
|
alertClassName="tournament-bracket__start-bracket-alert"
|
|
textClassName="stack horizontal md items-center text-center"
|
|
>
|
|
This bracket is a preview and subject to change
|
|
</Alert>
|
|
) : (
|
|
<Alert
|
|
variation="INFO"
|
|
alertClassName="tournament-bracket__start-bracket-alert"
|
|
textClassName="stack horizontal md items-center"
|
|
>
|
|
When everything looks good, finalize the bracket to start the
|
|
tournament{" "}
|
|
{adminCanStart() ? (
|
|
<SubmitButton
|
|
variant="outlined"
|
|
size="tiny"
|
|
testId="finalize-bracket-button"
|
|
>
|
|
Finalize
|
|
</SubmitButton>
|
|
) : (
|
|
<Popover
|
|
buttonChildren={<>Finalize</>}
|
|
triggerClassName="tiny outlined"
|
|
>
|
|
Bracket can't be started yet as it is before the start
|
|
time
|
|
</Popover>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
</Form>
|
|
) : null}
|
|
{parentRouteData.hasStarted && myTeam ? (
|
|
<TournamentProgressPrompt ownedTeamId={myTeam.id} />
|
|
) : null}
|
|
{/* TODO: also hide this if out of the tournament */}
|
|
{!data.finalStandings &&
|
|
myTeam &&
|
|
parentRouteData.hasStarted &&
|
|
parentRouteData.ownTeam ? (
|
|
<AddSubsPopOver
|
|
members={myTeam.members}
|
|
inviteCode={parentRouteData.ownTeam.inviteCode}
|
|
/>
|
|
) : null}
|
|
{data.finalStandings ? (
|
|
<FinalStandings standings={data.finalStandings} />
|
|
) : null}
|
|
<div className="brackets-viewer" ref={ref}></div>
|
|
{!data.enoughTeams ? (
|
|
<div className="text-center text-lg font-semi-bold text-lighter">
|
|
Bracket will be shown here when at least{" "}
|
|
{TOURNAMENT.ENOUGH_TEAMS_TO_START} teams have registered
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutoRefresher() {
|
|
useAutoRefresh();
|
|
|
|
return null;
|
|
}
|
|
|
|
function appendStyleTagToHead(content: string) {
|
|
const head = document.head || document.getElementsByTagName("head")[0];
|
|
const style = document.createElement("style");
|
|
|
|
head.appendChild(style);
|
|
|
|
style.type = "text/css";
|
|
style.appendChild(document.createTextNode(content));
|
|
}
|
|
|
|
function useAutoRefresh() {
|
|
const { revalidate } = useRevalidator();
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const lastEvent = useEventSource(
|
|
tournamentBracketsSubscribePage(parentRouteData.event.id),
|
|
{
|
|
event: bracketSubscriptionKey(parentRouteData.event.id),
|
|
}
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!lastEvent) return;
|
|
|
|
const [matchIdRaw, scoreOneRaw, scoreTwoRaw, isOverRaw] =
|
|
lastEvent.split("-");
|
|
const matchId = Number(matchIdRaw);
|
|
const scoreOne = Number(scoreOneRaw);
|
|
const scoreTwo = Number(scoreTwoRaw);
|
|
const isOver = isOverRaw === "true";
|
|
|
|
if (isOver) {
|
|
// bracketsViewer.updateMatch can't advance bracket
|
|
// so we revalidate loader when the match is over
|
|
revalidate();
|
|
} else {
|
|
// TODO: shows 1 - "-" when updating match where other score is 0
|
|
// @ts-expect-error - brackets-viewer is not typed
|
|
window.bracketsViewer.updateMatch({
|
|
id: matchId,
|
|
opponent1: {
|
|
score: scoreOne,
|
|
},
|
|
opponent2: {
|
|
score: scoreTwo,
|
|
},
|
|
status: Status.Running,
|
|
});
|
|
}
|
|
}, [lastEvent, revalidate]);
|
|
}
|
|
|
|
function TournamentProgressPrompt({ ownedTeamId }: { ownedTeamId: number }) {
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
const { progress, currentMatchId, currentOpponent } = (() => {
|
|
let lowestStatus = Infinity;
|
|
let currentMatchId: number | undefined;
|
|
let currentOpponent: string | undefined;
|
|
|
|
for (const match of data.bracket.match) {
|
|
// BYE
|
|
if (match.opponent1 === null || match.opponent2 === null) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
(match.opponent1.id === ownedTeamId ||
|
|
match.opponent2.id === ownedTeamId) &&
|
|
lowestStatus > match.status
|
|
) {
|
|
lowestStatus = match.status;
|
|
currentMatchId = match.id;
|
|
const otherTeam =
|
|
match.opponent1.id === ownedTeamId
|
|
? match.opponent2
|
|
: match.opponent1;
|
|
currentOpponent = parentRouteData.teams.find(
|
|
(team) => team.id === otherTeam.id
|
|
)?.name;
|
|
}
|
|
}
|
|
|
|
return { progress: lowestStatus, currentMatchId, currentOpponent };
|
|
})();
|
|
|
|
if (progress === Infinity) {
|
|
console.error("Unexpected no status");
|
|
return null;
|
|
}
|
|
|
|
if (progress === Status.Waiting) {
|
|
return (
|
|
<TournamentProgressContainer>
|
|
<WaitingForMatchText />
|
|
</TournamentProgressContainer>
|
|
);
|
|
}
|
|
|
|
if (progress >= Status.Completed) {
|
|
return (
|
|
<TournamentProgressContainer>
|
|
Thanks for playing in {parentRouteData.event.name}!
|
|
</TournamentProgressContainer>
|
|
);
|
|
}
|
|
|
|
if (!currentMatchId || !currentOpponent) {
|
|
console.error("Unexpected no match id or opponent");
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<TournamentProgressContainer>
|
|
Current opponent: {currentOpponent}
|
|
<LinkButton
|
|
to={tournamentMatchPage({
|
|
matchId: currentMatchId,
|
|
eventId: parentRouteData.event.id,
|
|
})}
|
|
size="tiny"
|
|
variant="outlined"
|
|
>
|
|
View
|
|
</LinkButton>
|
|
</TournamentProgressContainer>
|
|
);
|
|
}
|
|
|
|
function AddSubsPopOver({
|
|
members,
|
|
inviteCode,
|
|
}: {
|
|
members: unknown[];
|
|
inviteCode: string;
|
|
}) {
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const { t } = useTranslation(["common", "tournament"]);
|
|
const [, copyToClipboard] = useCopyToClipboard();
|
|
|
|
const subsAvailableToAdd =
|
|
TOURNAMENT.TEAM_MAX_MEMBERS_BEFORE_START + 1 - members.length;
|
|
|
|
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
|
eventId: parentRouteData.event.id,
|
|
inviteCode,
|
|
})}`;
|
|
|
|
return (
|
|
<Popover
|
|
buttonChildren={<>{t("tournament:actions.addSub")}</>}
|
|
triggerClassName="tiny outlined ml-auto"
|
|
triggerTestId="add-sub-button"
|
|
containerClassName="mt-4"
|
|
contentClassName="text-xs"
|
|
>
|
|
{t("tournament:actions.sub.prompt", { count: subsAvailableToAdd })}
|
|
{subsAvailableToAdd > 0 ? (
|
|
<>
|
|
<Divider className="my-2" />
|
|
<div>{t("tournament:actions.shareLink", { inviteLink })}</div>
|
|
<div className="my-2 flex justify-center">
|
|
<Button
|
|
size="tiny"
|
|
onClick={() => copyToClipboard(inviteLink)}
|
|
variant="minimal"
|
|
className="tiny"
|
|
testId="copy-invite-link-button"
|
|
>
|
|
{t("common:actions.copyToClipboard")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function FinalStandings({ standings }: { standings: FinalStanding[] }) {
|
|
const parentRouteData = useOutletContext<TournamentLoaderData>();
|
|
const [viewAll, setViewAll] = React.useState(false);
|
|
|
|
if (standings.length < 3) {
|
|
console.error("Unexpectedly few standings");
|
|
return null;
|
|
}
|
|
|
|
const [first, second, third, ...rest] = standings;
|
|
|
|
const nonTopThreePlacements = viewAll
|
|
? removeDuplicates(rest.map((s) => s.placement))
|
|
: [];
|
|
|
|
return (
|
|
<div className="tournament-bracket__standings">
|
|
{[third, first, second].map((standing) => {
|
|
return (
|
|
<div
|
|
className="tournament-bracket__standing"
|
|
key={standing.tournamentTeam.id}
|
|
data-placement={standing.placement}
|
|
>
|
|
<div>
|
|
<Placement placement={standing.placement} size={40} />
|
|
</div>
|
|
<Link
|
|
to={tournamentTeamPage({
|
|
eventId: parentRouteData.event.id,
|
|
tournamentTeamId: standing.tournamentTeam.id,
|
|
})}
|
|
className="tournament-bracket__standing__team-name tournament-bracket__standing__team-name__big"
|
|
>
|
|
{standing.tournamentTeam.name}
|
|
</Link>
|
|
<div className="stack horizontal sm flex-wrap justify-center">
|
|
{standing.players.map((player) => {
|
|
return (
|
|
<Link
|
|
to={userPage(player)}
|
|
key={player.id}
|
|
className="stack items-center text-xs"
|
|
>
|
|
<Avatar user={player} size="xxs" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="stack horizontal sm flex-wrap justify-center">
|
|
{standing.players.map((player) => {
|
|
return (
|
|
<div key={player.id} className="stack items-center">
|
|
{player.country ? (
|
|
<Flag countryCode={player.country} tiny />
|
|
) : null}
|
|
<Link
|
|
to={userPage(player)}
|
|
className="stack items-center text-xs mt-auto"
|
|
>
|
|
{player.discordName}
|
|
</Link>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{nonTopThreePlacements.map((placement) => {
|
|
return (
|
|
<React.Fragment key={placement}>
|
|
<Divider className="tournament-bracket__stadings__full-row-taker">
|
|
<Placement placement={placement} />
|
|
</Divider>
|
|
<div className="stack xl horizontal justify-center tournament-bracket__stadings__full-row-taker">
|
|
{standings
|
|
.filter((s) => s.placement === placement)
|
|
.map((standing) => {
|
|
return (
|
|
<div
|
|
className="tournament-bracket__standing"
|
|
key={standing.tournamentTeam.id}
|
|
>
|
|
<Link
|
|
to={tournamentTeamPage({
|
|
eventId: parentRouteData.event.id,
|
|
tournamentTeamId: standing.tournamentTeam.id,
|
|
})}
|
|
className="tournament-bracket__standing__team-name"
|
|
>
|
|
{standing.tournamentTeam.name}
|
|
</Link>
|
|
<div className="stack horizontal sm flex-wrap justify-center">
|
|
{standing.players.map((player) => {
|
|
return (
|
|
<Link
|
|
to={userPage(player)}
|
|
key={player.id}
|
|
className="stack items-center text-xs"
|
|
>
|
|
<Avatar user={player} size="xxs" />
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="stack horizontal sm flex-wrap justify-center">
|
|
{standing.players.map((player) => {
|
|
return (
|
|
<div key={player.id} className="stack items-center">
|
|
{player.country ? (
|
|
<Flag countryCode={player.country} tiny />
|
|
) : null}
|
|
<Link
|
|
to={userPage(player)}
|
|
className="stack items-center text-xs mt-auto"
|
|
>
|
|
{player.discordName}
|
|
</Link>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
<div />
|
|
<Button
|
|
variant="outlined"
|
|
className="tournament-bracket__standings__show-more"
|
|
size="tiny"
|
|
onClick={() => setViewAll((v) => !v)}
|
|
>
|
|
{viewAll ? "Show less" : "Show more"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TournamentProgressContainer({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="stack items-center">
|
|
<div className="tournament-bracket__progress">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WaitingForMatchText() {
|
|
const [showDot, setShowDot] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setShowDot((prev) => !prev);
|
|
}, 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
Waiting for match..
|
|
<span className={clsx({ invisible: !showDot })}>.</span>
|
|
</div>
|
|
);
|
|
}
|