mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-30 07:44:43 -05:00
335 lines
8.3 KiB
TypeScript
335 lines
8.3 KiB
TypeScript
import clsx from "clsx";
|
|
import { Check } from "lucide-react";
|
|
import type * as React from "react";
|
|
import { useState } from "react";
|
|
import { Radio, RadioGroup } from "react-aria-components";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useWebHaptics } from "web-haptics/react";
|
|
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
|
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
|
import type { CommonUser } from "~/utils/kysely.server";
|
|
import { Avatar } from "../Avatar";
|
|
import { SendouButton } from "../elements/Button";
|
|
import { SendouTabPanel } from "../elements/Tabs";
|
|
import { ModeImage, StageImage } from "../Image";
|
|
import styles from "./MatchActionTab.module.css";
|
|
import { TAB_KEYS } from "./MatchTabs";
|
|
import {
|
|
MatchTimeline,
|
|
type MatchTimelineProps,
|
|
type TimelineMap,
|
|
} from "./MatchTimeline";
|
|
|
|
const LONG_TEAM_NAME_THRESHOLD = 16;
|
|
|
|
interface ActionTabTeam {
|
|
id: number;
|
|
name: string;
|
|
avatar?: string;
|
|
}
|
|
|
|
interface SetEndingData extends MatchTimelineProps {
|
|
score: { alpha: number; bravo: number };
|
|
currentRosters: { alpha: CommonUser[]; bravo: CommonUser[] };
|
|
setEndingTeamIds: number[];
|
|
}
|
|
|
|
interface MatchActionTabProps {
|
|
teams: [ActionTabTeam, ActionTabTeam];
|
|
ownTeamId: number | null;
|
|
stageId: StageId;
|
|
mode: ModeShort;
|
|
withPoints: boolean;
|
|
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
|
|
isSubmitting?: boolean;
|
|
setEnding?: SetEndingData;
|
|
actionButtons?: React.ReactNode;
|
|
secondaryAction?: React.ReactNode;
|
|
}
|
|
|
|
export function MatchActionTab({
|
|
teams,
|
|
ownTeamId,
|
|
stageId,
|
|
mode,
|
|
withPoints,
|
|
onSubmit,
|
|
isSubmitting,
|
|
setEnding,
|
|
actionButtons,
|
|
secondaryAction,
|
|
}: MatchActionTabProps) {
|
|
const { t } = useTranslation(["q", "game-misc", "common"]);
|
|
const [winnerId, setWinnerId] = useState<number | null>(null);
|
|
const [isKo, setIsKo] = useState(false);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const { trigger } = useWebHaptics();
|
|
|
|
const canSubmit = winnerId !== null;
|
|
|
|
const isOnTeam =
|
|
ownTeamId != null &&
|
|
(teams[0].id === ownTeamId || teams[1].id === ownTeamId);
|
|
|
|
const submit = () => {
|
|
if (winnerId === null) return;
|
|
const submitPoints: [number, number] | undefined = withPoints
|
|
? isKo
|
|
? winnerId === teams[0].id
|
|
? [100, 0]
|
|
: [0, 100]
|
|
: [0, 0]
|
|
: undefined;
|
|
onSubmit?.({ winnerId, points: submitPoints });
|
|
};
|
|
|
|
return (
|
|
<SendouTabPanel id={TAB_KEYS.ACTION}>
|
|
{confirming && winnerId !== null && setEnding ? (
|
|
<SetEndingConfirmation
|
|
setEnding={setEnding}
|
|
stageId={stageId}
|
|
mode={mode}
|
|
winnerId={winnerId}
|
|
teams={teams}
|
|
withPoints={withPoints}
|
|
isKo={isKo}
|
|
isSubmitting={isSubmitting}
|
|
onBack={() => setConfirming(false)}
|
|
onConfirm={submit}
|
|
/>
|
|
) : (
|
|
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
|
|
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
|
|
{actionButtons ? (
|
|
<div className={styles.actionButtons}>{actionButtons}</div>
|
|
) : null}
|
|
|
|
<RadioGroup
|
|
value={winnerId !== null ? String(winnerId) : null}
|
|
onChange={(value) => {
|
|
const selectedId = Number(value);
|
|
setWinnerId(selectedId);
|
|
|
|
const isEnemySelection = isOnTeam && selectedId !== ownTeamId;
|
|
if (isEnemySelection) {
|
|
trigger([
|
|
{ duration: 40, intensity: 0.7 },
|
|
{ delay: 40, duration: 40, intensity: 0.7 },
|
|
{ delay: 40, duration: 40, intensity: 0.9 },
|
|
{ delay: 40, duration: 50, intensity: 0.6 },
|
|
]);
|
|
} else {
|
|
trigger([
|
|
{ duration: 30 },
|
|
{ delay: 60, duration: 40, intensity: 1 },
|
|
]);
|
|
}
|
|
}}
|
|
isDisabled={isSubmitting}
|
|
aria-label={t("q:match.action.selectWinner")}
|
|
className={styles.selectionRow}
|
|
>
|
|
<TeamRadioOption
|
|
team={teams[0]}
|
|
isOwnTeam={teams[0].id === ownTeamId}
|
|
hideLabel={ownTeamId === null}
|
|
className={styles.alpha}
|
|
testId="winner-radio-1"
|
|
/>
|
|
<StageImage
|
|
stageId={stageId}
|
|
width={90}
|
|
className={styles.stageImage}
|
|
containerClassName={styles.stageImageWrapper}
|
|
/>
|
|
<div className={styles.stageLabel}>
|
|
<ModeImage mode={mode} size={14} />
|
|
<span>{shortStageName(t(`game-misc:STAGE_${stageId}`))}</span>
|
|
</div>
|
|
<TeamRadioOption
|
|
team={teams[1]}
|
|
isOwnTeam={teams[1].id === ownTeamId}
|
|
hideLabel={ownTeamId == null}
|
|
className={clsx(styles.bravo)}
|
|
testId="winner-radio-2"
|
|
/>
|
|
</RadioGroup>
|
|
|
|
{withPoints ? (
|
|
<div className={styles.ko}>
|
|
<label className={styles.koLabel}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isKo}
|
|
onChange={(e) => setIsKo(e.target.checked)}
|
|
data-testid="ko-checkbox"
|
|
/>
|
|
{t("q:match.action.ko")}
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
<SendouButton
|
|
variant="primary"
|
|
isDisabled={!canSubmit || isSubmitting}
|
|
onPress={() => {
|
|
if (winnerId === null) return;
|
|
if (setEnding?.setEndingTeamIds.includes(winnerId)) {
|
|
setConfirming(true);
|
|
} else {
|
|
submit();
|
|
}
|
|
}}
|
|
className={styles.submit}
|
|
testId="report-score-button"
|
|
>
|
|
{t("common:actions.submit")}
|
|
</SendouButton>
|
|
</div>
|
|
)}
|
|
{secondaryAction}
|
|
</SendouTabPanel>
|
|
);
|
|
}
|
|
|
|
function SetEndingConfirmation({
|
|
setEnding,
|
|
stageId,
|
|
mode,
|
|
winnerId,
|
|
teams,
|
|
withPoints,
|
|
isKo,
|
|
isSubmitting,
|
|
onBack,
|
|
onConfirm,
|
|
}: {
|
|
setEnding: SetEndingData;
|
|
stageId: StageId;
|
|
mode: ModeShort;
|
|
winnerId: number;
|
|
teams: [ActionTabTeam, ActionTabTeam];
|
|
withPoints: boolean;
|
|
isKo: boolean;
|
|
isSubmitting?: boolean;
|
|
onBack: () => void;
|
|
onConfirm: () => void;
|
|
}) {
|
|
const { t } = useTranslation(["q", "common"]);
|
|
|
|
const winnerSide = winnerId === teams[0].id ? "ALPHA" : "BRAVO";
|
|
|
|
const newMap: TimelineMap = {
|
|
stageId,
|
|
mode,
|
|
timestamp: Date.now(),
|
|
winner: winnerSide,
|
|
rosters: setEnding.currentRosters,
|
|
points: withPoints
|
|
? isKo
|
|
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
|
|
: [0, 0]
|
|
: undefined,
|
|
};
|
|
|
|
const updatedScore = {
|
|
alpha: setEnding.score.alpha + (winnerSide === "ALPHA" ? 1 : 0),
|
|
bravo: setEnding.score.bravo + (winnerSide === "BRAVO" ? 1 : 0),
|
|
};
|
|
|
|
return (
|
|
<div className={styles.confirmationRoot}>
|
|
<div className={styles.confirmationMessage}>
|
|
{t("q:match.action.confirmSetEnding")}
|
|
</div>
|
|
<MatchTimeline
|
|
teams={setEnding.teams}
|
|
score={updatedScore}
|
|
maps={[...setEnding.maps, newMap]}
|
|
/>
|
|
<div className={styles.confirmationButtons}>
|
|
<SendouButton
|
|
variant="primary"
|
|
isDisabled={isSubmitting}
|
|
onPress={onConfirm}
|
|
testId="confirm-set-end-button"
|
|
>
|
|
{t("common:actions.confirm")}
|
|
</SendouButton>
|
|
<SendouButton variant="outlined" onPress={onBack}>
|
|
{t("common:actions.back")}
|
|
</SendouButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamRadioOption({
|
|
team,
|
|
isOwnTeam,
|
|
hideLabel,
|
|
className,
|
|
testId,
|
|
}: {
|
|
team: ActionTabTeam;
|
|
isOwnTeam: boolean;
|
|
hideLabel?: boolean;
|
|
className?: string;
|
|
testId?: string;
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
const isLongName = team.name.length > LONG_TEAM_NAME_THRESHOLD;
|
|
|
|
return (
|
|
<Radio
|
|
value={String(team.id)}
|
|
aria-label={team.name}
|
|
className={clsx(styles.teamRadioContainer, className)}
|
|
data-testid={testId}
|
|
>
|
|
{({ isSelected, isFocusVisible }) => (
|
|
<span
|
|
className={clsx(styles.teamRadio, {
|
|
[styles.selected]: isSelected,
|
|
[styles.focusVisible]: isFocusVisible,
|
|
})}
|
|
>
|
|
<span
|
|
className={clsx(styles.checkCircle, {
|
|
[styles.checkCircleSelected]: isSelected,
|
|
})}
|
|
>
|
|
{isSelected ? <Check size={14} /> : null}
|
|
</span>
|
|
<span className={styles.teamAvatarInfo}>
|
|
<Avatar url={team.avatar} identiconInput={team.name} size="xxs" />
|
|
<span className={styles.teamInfo}>
|
|
<span
|
|
className={clsx(styles.teamName, {
|
|
[styles.teamNameLong]: isLongName,
|
|
})}
|
|
>
|
|
{team.name}
|
|
</span>
|
|
{hideLabel ? null : (
|
|
<span
|
|
className={clsx(styles.teamLabel, {
|
|
[styles.myTeamLabel]: isOwnTeam,
|
|
[styles.opponentLabel]: !isOwnTeam,
|
|
})}
|
|
>
|
|
{isOwnTeam
|
|
? t("q:match.action.myTeam")
|
|
: t("q:match.action.opponent")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
)}
|
|
</Radio>
|
|
);
|
|
}
|