362 lines
10 KiB
TypeScript
362 lines
10 KiB
TypeScript
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
|
|
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
|
import React, { useCallback, useMemo } from "react";
|
|
import { MatchSlot } from "./match-slot";
|
|
import { Match } from "@/features/matches/types";
|
|
import { Team } from "@/features/teams/types";
|
|
import { useSheet } from "@/hooks/use-sheet";
|
|
import { MatchForm } from "./match-form";
|
|
import Sheet from "@/components/sheet/sheet";
|
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
|
import { endMatch, startMatch } from "@/features/matches/server";
|
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
|
|
|
interface MatchCardProps {
|
|
match: Match;
|
|
orders: Record<number, number>;
|
|
showControls?: boolean;
|
|
groupConfig?: {
|
|
num_groups: number;
|
|
advance_per_group: number;
|
|
};
|
|
}
|
|
|
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
|
match,
|
|
orders,
|
|
showControls,
|
|
groupConfig,
|
|
}) => {
|
|
const queryClient = useQueryClient();
|
|
const editSheet = useSheet();
|
|
const { playTrack, pause } = useSpotifyPlayback();
|
|
|
|
const getGroupLabel = useCallback((seed: number | undefined) => {
|
|
if (!seed || !groupConfig) return undefined;
|
|
|
|
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
|
const numGroups = groupConfig.num_groups;
|
|
const advancePerGroup = groupConfig.advance_per_group;
|
|
|
|
const pairIndex = Math.floor((seed - 1) / 2);
|
|
const isFirstInPair = (seed - 1) % 2 === 0;
|
|
|
|
if (isFirstInPair) {
|
|
const groupIndex = pairIndex % numGroups;
|
|
const rankIndex = Math.floor(pairIndex / numGroups);
|
|
|
|
const rank = rankIndex + 1;
|
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
|
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
|
|
|
return `${groupName} ${rankSuffix}`;
|
|
} else {
|
|
const groupIndex = (pairIndex + 1) % numGroups;
|
|
const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups);
|
|
|
|
const rank = rankIndex + 1;
|
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
|
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
|
|
|
return `${groupName} ${rankSuffix}`;
|
|
}
|
|
}, [groupConfig]);
|
|
|
|
const homeSlot = useMemo(
|
|
() => ({
|
|
from: orders[match.home_from_lid],
|
|
from_loser: match.home_from_loser,
|
|
team: match.home,
|
|
seed: match.home_seed,
|
|
cups: match.status === "ended" ? match.home_cups : undefined,
|
|
isWinner:
|
|
match.status === "ended" &&
|
|
match.home_cups !== undefined &&
|
|
match.away_cups !== undefined &&
|
|
match.home_cups > match.away_cups,
|
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
|
|
}),
|
|
[match, getGroupLabel]
|
|
);
|
|
const awaySlot = useMemo(
|
|
() => ({
|
|
from: orders[match.away_from_lid],
|
|
from_loser: match.away_from_loser,
|
|
team: match.away,
|
|
seed: match.away_seed,
|
|
cups: match.status === "ended" ? match.away_cups : undefined,
|
|
isWinner:
|
|
match.status === "ended" &&
|
|
match.away_cups !== undefined &&
|
|
match.home_cups !== undefined &&
|
|
match.away_cups > match.home_cups,
|
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
|
|
}),
|
|
[match, getGroupLabel]
|
|
);
|
|
|
|
const showToolbar = useMemo(
|
|
() => match.status === "ready" && showControls,
|
|
[match.status, showControls]
|
|
);
|
|
|
|
const showEditButton = useMemo(
|
|
() => showControls && match.status === "started",
|
|
[showControls, match.status]
|
|
);
|
|
|
|
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
|
|
|
|
const start = useServerMutation({
|
|
mutationFn: startMatch,
|
|
successMessage: "Match started!",
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: tournamentKeys.details(match.tournament.id),
|
|
});
|
|
},
|
|
});
|
|
|
|
const end = useServerMutation({
|
|
mutationFn: endMatch,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: tournamentKeys.details(match.tournament.id),
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleFormSubmit = useCallback(
|
|
async (data: {
|
|
home_cups: number;
|
|
away_cups: number;
|
|
ot_count: number;
|
|
}) => {
|
|
end.mutate({
|
|
data: {
|
|
...data,
|
|
matchId: match.id,
|
|
},
|
|
});
|
|
editSheet.close();
|
|
},
|
|
[match.id, editSheet]
|
|
);
|
|
|
|
const speak = useCallback((text: string): Promise<void> => {
|
|
return new Promise((resolve) => {
|
|
if (!("speechSynthesis" in window)) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
const voices = window.speechSynthesis.getVoices();
|
|
|
|
const preferredVoice =
|
|
voices.find(
|
|
(voice) =>
|
|
voice.lang.startsWith("en") && voice.name.includes("Daniel")
|
|
) ||
|
|
voices.find((voice) => voice.lang.startsWith("en") && voice.default);
|
|
|
|
if (preferredVoice) {
|
|
utterance.voice = preferredVoice;
|
|
}
|
|
|
|
utterance.rate = 0.9;
|
|
utterance.volume = 0.8;
|
|
utterance.pitch = 1.0;
|
|
|
|
utterance.onend = () => resolve();
|
|
utterance.onerror = () => resolve();
|
|
|
|
window.speechSynthesis.speak(utterance);
|
|
});
|
|
}, []);
|
|
|
|
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
|
|
return new Promise((resolve) => {
|
|
const songDuration = (team.song_end - team.song_start) * 1000;
|
|
|
|
playTrack(team.song_id, undefined, team.song_start * 1000);
|
|
|
|
setTimeout(async () => {
|
|
await pause();
|
|
resolve();
|
|
}, songDuration);
|
|
});
|
|
}, [playTrack, pause]);
|
|
|
|
const handleSpeakerClick = useCallback(async () => {
|
|
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
|
|
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const homeTeam = match.home as Team;
|
|
const awayTeam = match.away as Team;
|
|
|
|
await playTeamWalkout(homeTeam);
|
|
await speak(homeTeam.name);
|
|
await speak("versus");
|
|
await playTeamWalkout(awayTeam);
|
|
await speak(awayTeam.name);
|
|
await speak("have fun, good luck!");
|
|
|
|
} catch (error) {
|
|
console.warn('Walkout sequence error:', error);
|
|
await speak(`${match.home.name} vs. ${match.away.name}`);
|
|
}
|
|
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
|
|
|
|
const handleStart = useCallback(async () => {
|
|
start.mutate({
|
|
data: match.id,
|
|
});
|
|
|
|
// Skip announcements for regional tournaments
|
|
const isRegional = match.tournament?.regional === true;
|
|
|
|
// Play walkout sequence after starting the match (only for non-regional tournaments)
|
|
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
|
try {
|
|
const homeTeam = match.home as Team;
|
|
const awayTeam = match.away as Team;
|
|
|
|
await playTeamWalkout(homeTeam);
|
|
await speak(homeTeam.name);
|
|
await speak("versus");
|
|
await playTeamWalkout(awayTeam);
|
|
await speak(awayTeam.name);
|
|
await speak("have fun, good luck!");
|
|
} catch (error) {
|
|
console.warn('Auto-walkout sequence error:', error);
|
|
}
|
|
}
|
|
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
|
|
|
|
return (
|
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
|
<Text
|
|
c="dimmed"
|
|
fw="bolder"
|
|
px={6}
|
|
py={2}
|
|
style={{
|
|
backgroundColor: 'var(--mantine-color-body)'
|
|
}}
|
|
>
|
|
{match.order}
|
|
</Text>
|
|
<Flex align="stretch">
|
|
<Indicator
|
|
inline
|
|
processing={match.status === "started"}
|
|
color="red"
|
|
size={12}
|
|
disabled={match.status !== "started" || showEditButton}
|
|
>
|
|
<Card
|
|
w={showToolbar || showEditButton ? 200 : 220}
|
|
withBorder
|
|
pos="relative"
|
|
style={{
|
|
overflow: "visible",
|
|
backgroundColor: 'var(--mantine-color-body)',
|
|
borderColor: 'var(--mantine-color-default-border)',
|
|
boxShadow: 'var(--mantine-shadow-sm)',
|
|
}}
|
|
data-match-lid={match.lid}
|
|
>
|
|
<Card.Section withBorder p={0}>
|
|
<MatchSlot {...homeSlot} />
|
|
</Card.Section>
|
|
|
|
<Card.Section p={0} mb={-16}>
|
|
<MatchSlot {...awaySlot} />
|
|
</Card.Section>
|
|
|
|
{match.reset && (
|
|
<Text
|
|
pos="absolute"
|
|
top={-20}
|
|
left={8}
|
|
size="xs"
|
|
c="dimmed"
|
|
fw="bold"
|
|
>
|
|
* If necessary
|
|
</Text>
|
|
)}
|
|
|
|
{showControls && match.status !== "tbd" && (
|
|
<ActionIcon
|
|
pos="absolute"
|
|
bottom={-2}
|
|
left={-26}
|
|
size="sm"
|
|
variant="subtle"
|
|
color="gray"
|
|
onClick={handleSpeakerClick}
|
|
>
|
|
<SpeakerHighIcon size={12} />
|
|
</ActionIcon>
|
|
)}
|
|
</Card>
|
|
</Indicator>
|
|
|
|
{showToolbar && (
|
|
<Flex direction="column" justify="center" align="center">
|
|
<ActionIcon
|
|
color="green"
|
|
onClick={handleStart}
|
|
size="sm"
|
|
h="100%"
|
|
radius="sm"
|
|
ml={-4}
|
|
style={{
|
|
borderTopLeftRadius: 0,
|
|
borderBottomLeftRadius: 0,
|
|
}}
|
|
>
|
|
<PlayIcon size={14} />
|
|
</ActionIcon>
|
|
</Flex>
|
|
)}
|
|
|
|
|
|
{showEditButton && (
|
|
<Flex direction="column" justify="center" align="center">
|
|
<ActionIcon
|
|
color="blue"
|
|
onClick={editSheet.open}
|
|
size="sm"
|
|
h="100%"
|
|
radius="sm"
|
|
ml={-4}
|
|
style={{
|
|
borderTopLeftRadius: 0,
|
|
borderBottomLeftRadius: 0,
|
|
}}
|
|
>
|
|
<PencilIcon size={14} />
|
|
</ActionIcon>
|
|
</Flex>
|
|
)}
|
|
</Flex>
|
|
|
|
<Sheet title="Edit Match" {...editSheet.props}>
|
|
<MatchForm
|
|
match={match}
|
|
onSubmit={handleFormSubmit}
|
|
onCancel={editSheet.close}
|
|
/>
|
|
</Sheet>
|
|
</Flex>
|
|
);
|
|
};
|