mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 07:44:43 -05:00
522 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|