Files
flxn-app/src/features/bracket/components/match-card.tsx
2025-09-12 17:14:33 -05:00

243 lines
6.4 KiB
TypeScript

import { ActionIcon, Card, Flex, Text, Stack, 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 { 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";
interface MatchCardProps {
match: Match;
orders: Record<number, number>;
showControls?: boolean;
}
export const MatchCard: React.FC<MatchCardProps> = ({
match,
orders,
showControls,
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
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,
}),
[match]
);
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,
}),
[match]
);
const showToolbar = useMemo(
() => match.status === "ready" && showControls,
[match.status, showControls]
);
const showEditButton = useMemo(
() => showControls && match.status === "started",
[showControls, match.status]
);
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 handleStart = useCallback(async () => {
await start.mutate({
data: match.id,
});
}, [match]);
const handleFormSubmit = useCallback(
async (data: {
home_cups: number;
away_cups: number;
ot_count: number;
}) => {
await end.mutate({
data: {
...data,
matchId: match.id,
},
});
editSheet.close();
},
[match.id, editSheet]
);
const handleSpeakerClick = useCallback(() => {
if ("speechSynthesis" in window && match.home?.name && match.away?.name) {
const utterance = new SpeechSynthesisUtterance(
`${match.home.name} vs. ${match.away.name}`
);
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;
window.speechSynthesis.speak(utterance);
}
}, [match.home?.name, match.away?.name]);
return (
<Flex direction="row" align="center" justify="end" gap={8}>
<Text c="dimmed" fw="bolder">
{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" }}
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 && (
<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>
);
};