sendou.ink/app/features/tournament-bracket/components/Bracket/Match.tsx
2026-06-27 10:26:30 +03:00

522 lines
13 KiB
TypeScript

import clsx from "clsx";
import { Eye } from "lucide-react";
import type * as React from "react";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { useUser } from "~/features/auth/core/user";
import { TournamentStream } from "~/features/tournament/components/TournamentStream";
import {
useTournament,
useTournamentVods,
} from "~/features/tournament/routes/to.$id";
import { matchEndedEarly } from "~/features/tournament-match/tournament-match-utils";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { databaseTimestampToDate } from "~/utils/dates";
import type { Unpacked } from "~/utils/types";
import {
tournamentMatchPage,
tournamentStreamsPage,
vodUrl,
} from "~/utils/urls";
import type { Bracket } from "../../core/Bracket";
import * as Deadline from "../../core/Deadline";
import type { TournamentData } from "../../core/Tournament.server";
import parentStyles from "../../tournament-bracket.module.css";
import styles from "./bracket.module.css";
type LineType = "none" | "straight" | "curve-up" | "curve-down";
interface MatchProps {
match: Unpacked<TournamentData["data"]["match"]>;
isPreview?: boolean;
type?: "winners" | "losers" | "grands" | "groups";
group?: string;
roundNumber: number;
showSimulation: boolean;
bracket: Bracket;
hideMatchTimer?: boolean;
lineType?: LineType;
lineVerticalExtend?: number;
spoilerCensor?: "full" | "score-only";
}
export function Match(props: MatchProps) {
const isBye = !props.match.opponent1 || !props.match.opponent2;
if (isBye) {
return (
<div className={clsx(styles.matchBye, styles.matchWrapper)}>
<MatchLine
lineType={props.lineType}
verticalExtend={props.lineVerticalExtend}
/>
</div>
);
}
return (
<div className={styles.matchWrapper}>
<MatchHeader {...props} />
<MatchContent {...props}>
<MatchRow {...props} side={1} />
<div className={styles.matchSeparator} />
<MatchRow {...props} side={2} />
</MatchContent>
{!props.hideMatchTimer ? (
<MatchTimer match={props.match} bracket={props.bracket} />
) : null}
<MatchLine
lineType={props.lineType}
verticalExtend={props.lineVerticalExtend}
/>
</div>
);
}
function MatchHeader({ match, type, roundNumber, group }: MatchProps) {
const tournament = useTournament();
const vods = useTournamentVods();
const streamingParticipants = tournament.streamingParticipantIds ?? [];
const prefix = () => {
if (type === "winners") return "WB ";
if (type === "losers") return "LB ";
if (type === "grands") return "GF ";
if (type === "groups") return `${group}`;
return "";
};
const isOver =
match.opponent1?.result === "win" || match.opponent2?.result === "win";
const matchVods = isOver ? vods.filter((v) => v.matchId === match.id) : [];
const hasStreams = () => {
if (isOver || !match.opponent1?.id || !match.opponent2?.id) return false;
if (
tournament.ctx.castedMatchesInfo?.castedMatches.some(
(cm) => cm.matchId === match.id,
)
) {
return true;
}
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
(teamId) =>
tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
);
return streamingParticipants.some((p) => matchParticipants.includes(p));
};
const toBeCasted =
!isOver &&
tournament.ctx.castedMatchesInfo?.lockedMatches?.some(
(lm) => lm.matchId === match.id,
);
return (
<div className={styles.matchHeader}>
<div className={styles.matchHeaderBox}>
{prefix()}
{roundNumber}.{match.number}
</div>
{toBeCasted ? (
<SendouPopover
trigger={
<SendouButton
className={clsx(
styles.matchHeaderBox,
styles.matchHeaderBoxButton,
)}
>
🔒 CAST
</SendouButton>
}
>
Match is scheduled to be casted
</SendouPopover>
) : hasStreams() && match.startedAt ? (
<SendouPopover
placement="top"
popoverClassName="w-max"
trigger={
<SendouButton
className={clsx(
styles.matchHeaderBox,
styles.matchHeaderBoxButton,
)}
>
🔴 LIVE
</SendouButton>
}
>
<MatchStreams match={match} />
</SendouPopover>
) : matchVods.length > 0 ? (
<SendouPopover
placement="top"
popoverClassName="w-max"
trigger={
<SendouButton
className={clsx(
styles.matchHeaderBox,
styles.matchHeaderBoxButton,
)}
>
📺 VOD
</SendouButton>
}
>
<MatchVods vods={matchVods} />
</SendouPopover>
) : null}
</div>
);
}
function MatchContent({
match,
isPreview,
children,
}: MatchProps & { children: React.ReactNode }) {
const tournament = useTournament();
if (!isPreview) {
return (
<Link
className={styles.match}
to={tournamentMatchPage({
tournamentId: tournament.ctx.id,
matchId: match.id,
})}
data-match-id={match.id}
>
{children}
</Link>
);
}
return <div className={styles.match}>{children}</div>;
}
function MatchRow({
match,
side,
isPreview,
showSimulation,
bracket,
spoilerCensor,
}: MatchProps & { side: 1 | 2 }) {
const user = useUser();
const tournament = useTournament();
const opponentKey = `opponent${side}` as const;
const opponent = match[`opponent${side}`];
const score = () => {
if (spoilerCensor) return null;
if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null;
const opponentScore = opponent!.score;
const opponentResult = opponent!.result;
// Display W/L as the score might not reflect the winner set in the early ending
const round = bracket.data.round.find((r) => r.id === match.round_id);
if (
round?.maps &&
matchEndedEarly({
opponentOne: match.opponent1,
opponentTwo: match.opponent2,
count: round.maps.count,
countType: round.maps.type,
})
) {
if (opponentResult === "win") return "W";
if (opponentResult === "loss") return "L";
}
return opponentScore ?? 0;
};
const isLoser = spoilerCensor ? false : opponent?.result === "loss";
const { team, simulated } = (() => {
if (opponent?.id) {
return { team: tournament.teamById(opponent.id), simulated: false };
}
const simulated = showSimulation
? bracket.simulatedMatch(match.id)
: undefined;
const simulatedOpponent = simulated?.[opponentKey];
return simulatedOpponent?.id
? { team: tournament.teamById(simulatedOpponent.id), simulated: true }
: { team: null, simulated: true };
})();
const ownTeam = tournament.teamMemberOfByUser(user);
const logoSrc = team ? tournament.tournamentTeamLogoSrc(team) : null;
const showAvatar = spoilerCensor === "full" ? false : !simulated && team;
const isBigSeedNumber =
spoilerCensor === "full" ? false : team?.seed && team.seed > 99;
const displayedSeed = spoilerCensor === "full" ? null : team?.seed;
const displayedName =
spoilerCensor === "full" ? "???" : (team?.name ?? "???");
return (
<div
className={clsx("stack horizontal", { "text-lighter": isLoser })}
data-participant-id={team?.id}
title={
spoilerCensor === "full"
? undefined
: team?.members.map((m) => m.username).join(", ")
}
>
<div
className={clsx(styles.matchSeed, {
"text-lighter italic opaque": simulated,
[styles.matchSeedWide]: isBigSeedNumber,
})}
>
{displayedSeed}
</div>
{showAvatar ? (
<Avatar
size="xxxs"
url={logoSrc}
identiconInput={team!.name}
className="mr-1"
/>
) : null}
<div
className={clsx(styles.matchTeamName, {
"text-theme-secondary":
!simulated && ownTeam && ownTeam?.id === team?.id,
"text-lighter italic opaque": simulated,
[styles.matchTeamNameNarrow]:
// either but not both
(showAvatar || isBigSeedNumber) && !(showAvatar && isBigSeedNumber),
// both
[styles.matchTeamNameNarrowest]: showAvatar && isBigSeedNumber,
invisible: !team,
})}
>
{displayedName}
</div>{" "}
<div className={styles.matchScore}>{score()}</div>
</div>
);
}
function MatchStreams({ match }: Pick<MatchProps, "match">) {
const tournament = useTournament();
if (!match.opponent1?.id || !match.opponent2?.id) {
return null;
}
const castingAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find(
(cm) => cm.matchId === match.id,
)?.twitchAccount;
const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap(
(teamId) => tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [],
);
const streamsOfThisMatch = tournament.streams.filter(
(stream) =>
(stream.userId && matchParticipants.includes(stream.userId)) ||
stream.twitchUserName === castingAccount,
);
if (streamsOfThisMatch.length === 0) {
return (
<div className={parentStyles.streamPopover}>
After all there seems to be no streams of this match. Check the{" "}
<Link to={tournamentStreamsPage(tournament.ctx.id)}>streams page</Link>{" "}
for all the available streams.
</div>
);
}
return (
<div
className={clsx("stack md justify-center", parentStyles.streamPopover)}
data-testid="stream-popover"
>
{streamsOfThisMatch.map((stream) => (
<TournamentStream
key={stream.twitchUserName}
stream={stream}
withThumbnail={false}
/>
))}
</div>
);
}
interface MatchVodsProps {
vods: Array<{
matchId: number;
userId: number | null;
platform: string;
account: string;
platformVideoId: string;
timestampSeconds: number;
viewCount: number;
}>;
}
function MatchVods({ vods }: MatchVodsProps) {
const tournament = useTournament();
return (
<div className={parentStyles.vodGrid}>
{vods.map((vod) => {
const team = vod.userId
? tournament.ctx.teams.find((t) =>
t.members.some((m) => m.userId === vod.userId),
)
: null;
const user = team?.members.find((m) => m.userId === vod.userId);
return (
<a
key={`${vod.platformVideoId}-${vod.account}`}
href={vodUrl(vod)}
target="_blank"
rel="noopener noreferrer"
>
<span className={parentStyles.vodUser}>
{user ? (
<>
<Avatar size="xxs" user={user} />
<span className="font-semi-bold">{user.username}</span>
</>
) : (
<span className="font-semi-bold">{vod.account}</span>
)}
</span>
<span
className={clsx("text-theme-secondary", parentStyles.vodTeamName)}
>
{user ? team?.name : null}
</span>
<span className="text-lighter stack horizontal xs items-center">
<Eye size={12} />
{vod.viewCount.toLocaleString()}
</span>
</a>
);
})}
</div>
);
}
function MatchTimer({ match, bracket }: Pick<MatchProps, "match" | "bracket">) {
const tournament = useTournament();
if (tournament.isLeagueDivision) return null;
if (!match.startedAt) return null;
const isOver =
match.opponent1?.result === "win" || match.opponent2?.result === "win";
if (isOver) return null;
const isLocked = tournament.ctx.castedMatchesInfo?.lockedMatches?.some(
(lm) => lm.matchId === match.id,
);
if (isLocked) return null;
const round = bracket.data.round.find((r) => r.id === match.round_id);
const bestOf = round?.maps?.count;
if (!bestOf) return null;
return (
<MatchTimerInner
startedAt={match.startedAt}
gamesCompleted={
(match.opponent1?.score ?? 0) + (match.opponent2?.score ?? 0)
}
bestOf={bestOf}
/>
);
}
interface MatchTimerInnerProps {
startedAt: number;
gamesCompleted: number;
bestOf: number;
}
function MatchTimerInner({
startedAt,
gamesCompleted,
bestOf,
}: MatchTimerInnerProps) {
const startedAtDate = databaseTimestampToDate(startedAt);
const now = useAutoRerender("minute", { alignTo: startedAtDate });
const elapsedMinutes = Math.floor(
(now.getTime() - startedAtDate.getTime()) / 60_000,
);
const status = Deadline.matchStatus({
elapsedMinutes,
gamesCompleted,
maxGamesCount: bestOf,
});
const displayText = elapsedMinutes >= 60 ? "1h+" : `${elapsedMinutes}m`;
return (
<div className={styles.matchTimer} data-testid="bracket-match-timer">
<div
className={clsx(styles.matchHeaderBox, styles.matchHeaderBoxButton, {
[styles.matchTimerWarning]: status === "warning",
[styles.matchTimerError]: status === "error",
})}
>
{displayText}
</div>
</div>
);
}
interface MatchLineProps {
lineType?: LineType;
verticalExtend?: number;
}
function MatchLine({ lineType, verticalExtend }: MatchLineProps) {
if (!lineType || lineType === "none") return null;
const lineClass =
lineType === "straight"
? styles.matchLineStraight
: lineType === "curve-up"
? styles.matchLineCurveUp
: styles.matchLineCurveDown;
const style = verticalExtend
? ({
"--bracket-vertical-extend": `${verticalExtend}px`,
} as React.CSSProperties)
: undefined;
return (
<div className={clsx(styles.matchLineContainer, lineClass)} style={style}>
{lineType === "curve-down" ? (
<div className={styles.matchLineConnectorDown} style={style} />
) : null}
{lineType === "curve-up" ? (
<div className={styles.matchLineConnectorUp} style={style} />
) : null}
</div>
);
}