This commit is contained in:
yohlo
2026-07-14 00:12:39 -07:00
parent e6c72a5789
commit d3809b5805
17 changed files with 504 additions and 168 deletions
@@ -1,7 +1,8 @@
import React, { useMemo } from "react";
import { Text, ScrollArea } from "@mantine/core";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, ScrollArea, Box } from "@mantine/core";
import { BracketData } from "../types";
import { Bracket } from "./bracket";
import MatchDock from "./match-dock";
import useAppShellHeight from "@/hooks/use-appshell-height";
import { Match } from "@/features/matches/types";
import styles from "./styles.module.css";
@@ -17,6 +18,8 @@ interface BracketViewProps {
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
const height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false);
const orders = useMemo(() => {
const map: Record<number, number> = {};
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
@@ -24,7 +27,60 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
return map;
}, [bracket.winners, bracket.losers]);
return <ScrollArea
const [selectedLid, setSelectedLid] = useState<number | null>(null);
const handleMatchTap = useCallback((match: Match) => {
setSelectedLid((prev) => (prev === match.lid ? null : match.lid));
}, []);
const closeDock = useCallback(() => setSelectedLid(null), []);
const selectedMatch = useMemo(() => {
if (selectedLid == null) return null;
return (
[...bracket.winners.flat(), ...bracket.losers.flat()].find(
(match) => match.lid === selectedLid
) ?? null
);
}, [bracket, selectedLid]);
useEffect(() => {
if (hasAutoScrolled.current) return;
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
(match) => !match.bye
);
const target =
matches.find((match) => match.status === "started") ??
matches.find((match) => match.status === "ready");
if (!target) return;
const viewport = viewportRef.current;
const element = viewport?.querySelector(`[data-match-lid="${target.lid}"]`);
if (!viewport || !element) return;
hasAutoScrolled.current = true;
const viewportRect = viewport.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const left =
elementRect.left - viewportRect.left + viewport.scrollLeft - 40;
const top =
elementRect.top -
viewportRect.top +
viewport.scrollTop -
(viewport.clientHeight - elementRect.height) / 2;
viewport.scrollTo({
left: Math.max(0, left),
top: Math.max(0, top),
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
}, [bracket]);
return <Box pos="relative">
<ScrollArea
viewportRef={viewportRef}
h={`calc(${height})`}
className={styles["bracket-container"]}
style={{
@@ -37,17 +93,19 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
<Text fw={600} size="md" m={16}>
Winners Bracket
</Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
</Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
</div>
)}
</ScrollArea>
<MatchDock match={selectedMatch} onClose={closeDock} />
</Box>
};
export default BracketView;
@@ -11,6 +11,8 @@ interface BracketProps {
num_groups: number;
advance_per_group: number;
};
onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null;
}
export const Bracket: React.FC<BracketProps> = ({
@@ -18,6 +20,8 @@ export const Bracket: React.FC<BracketProps> = ({
orders,
showControls,
groupConfig,
onMatchTap,
selectedMatchLid,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
@@ -138,6 +142,8 @@ export const Bracket: React.FC<BracketProps> = ({
orders={orders}
showControls={showControls}
groupConfig={groupConfig}
onTap={onMatchTap}
selected={selectedMatchLid === match.lid}
/>
</div>
)
+37 -45
View File
@@ -12,6 +12,8 @@ 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";
import { getGroupLabel } from "../utils/group-label";
import styles from "./styles.module.css";
interface MatchCardProps {
match: Match;
@@ -21,6 +23,8 @@ interface MatchCardProps {
num_groups: number;
advance_per_group: number;
};
onTap?: (match: Match) => void;
selected?: boolean;
}
export const MatchCard: React.FC<MatchCardProps> = ({
@@ -28,50 +32,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
orders,
showControls,
groupConfig,
onTap,
selected,
}) => {
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 totalQualifiedTeams = numGroups * advancePerGroup;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
const wildcardNumber = seed - totalQualifiedTeams;
return `Wildcard ${wildcardNumber}`;
}
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 canTap = !!(onTap && match.home && match.away);
const homeSlot = useMemo(
() => ({
@@ -85,9 +53,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
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,
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed, groupConfig) : undefined,
}),
[match, getGroupLabel]
[match, orders, groupConfig]
);
const awaySlot = useMemo(
() => ({
@@ -101,9 +69,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
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,
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed, groupConfig) : undefined,
}),
[match, getGroupLabel]
[match, orders, groupConfig]
);
const showToolbar = useMemo(
@@ -273,10 +241,31 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220}
withBorder
pos="relative"
className={canTap ? styles["tappable-card"] : undefined}
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
onKeyDown={
canTap
? (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTap!(match);
}
}
: undefined
}
aria-label={
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: 'var(--mantine-color-default-border)',
borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid}
@@ -310,7 +299,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
size="sm"
variant="subtle"
color="gray"
onClick={handleSpeakerClick}
onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup"
>
<SpeakerHighIcon size={12} />
@@ -0,0 +1,219 @@
import { Suspense } from "react";
import {
ActionIcon,
Box,
Center,
CloseButton,
Group,
Indicator,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { FootballHelmetIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "@/features/matches/types";
import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
interface MatchDockProps {
match: Match | null;
onClose: () => void;
}
const TeamRow = ({
team,
cups,
isWinner,
isRegional,
ended,
}: {
team: NonNullable<Match["home"]>;
cups: number;
isWinner: boolean;
isRegional: boolean;
ended: boolean;
}) => {
const navigate = useNavigate();
return (
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group
gap="sm"
wrap="nowrap"
style={{ flex: 1, minWidth: 0, cursor: "pointer" }}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
<TeamAvatar
team={team}
size={32}
radius="sm"
winner={ended && isWinner}
isRegional={isRegional}
/>
<Text size="sm" fw={ended && isWinner ? 700 : 500} lineClamp={1}>
{team.name}
</Text>
</Group>
{ended && (
<AnimatedScore
value={cups}
size="lg"
fw={700}
c={isWinner ? "green" : "dimmed"}
style={{ minWidth: 28, textAlign: "center" }}
/>
)}
</Group>
);
};
const MatchDock = ({ match, onClose }: MatchDockProps) => {
const reduceMotion = useReducedMotion();
const h2hSheet = useSheet();
const hasPrivate = match?.home?.private || match?.away?.private;
const ended = match?.status === "ended";
const started = match?.status === "started";
return (
<>
<AnimatePresence>
{match && match.home && match.away && (
<motion.div
key="match-dock"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
transition={{ duration: 0.2, ease: EASE }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 5,
padding: "0 12px 12px",
}}
>
<Paper withBorder shadow="md" radius="lg" px="md" py="sm">
<Stack gap="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{started && (
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
)}
<Text size="xs" fw={600} c="dimmed" lineClamp={1}>
Match {match.order} · Round {match.round + 1}
{match.is_losers_bracket && " (Losers)"}
</Text>
</Group>
<CloseButton
size="sm"
onClick={onClose}
aria-label="Close match actions"
/>
</Group>
<TeamRow
team={match.home}
cups={match.home_cups}
isWinner={match.home_cups > match.away_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<Box
style={{
height: 1,
backgroundColor: "var(--mantine-color-default-border)",
}}
/>
<TeamRow
team={match.away}
cups={match.away_cups}
isWinner={match.away_cups > match.home_cups}
isRegional={match.tournament.regional === true}
ended={ended}
/>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
<Suspense
fallback={
<Center py={4}>
<Loader size="xs" />
</Center>
}
>
<EmojiBar matchId={match.id} />
</Suspense>
</Box>
{!hasPrivate && (
<Tooltip label="Head to Head" withArrow position="top">
<ActionIcon
variant="subtle"
size="sm"
onClick={h2hSheet.open}
aria-label="View head-to-head"
w={40}
style={{ flexShrink: 0 }}
>
<Group
style={{ position: "relative", width: 27.5, height: 16 }}
>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
left: 0,
top: 0,
transform: "rotate(25deg)",
}}
/>
<FootballHelmetIcon
size={14}
style={{
position: "absolute",
right: 0,
top: 0,
transform: "scaleX(-1) rotate(25deg)",
}}
/>
</Group>
</ActionIcon>
</Tooltip>
)}
</Group>
</Stack>
</Paper>
</motion.div>
)}
</AnimatePresence>
{match?.home && match?.away && h2hSheet.isOpen && (
<Sheet title="Head to Head" {...h2hSheet.props}>
<TeamHeadToHeadSheet
team1={match.home}
team2={match.away}
isOpen={h2hSheet.props.opened}
/>
</Sheet>
)}
</>
);
};
export default MatchDock;
@@ -6,4 +6,20 @@
.bracket-container {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
}
.tappable-card {
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
@media (prefers-reduced-motion: no-preference) {
.tappable-card {
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1),
border-color 150ms ease-out;
}
.tappable-card:active {
transform: scale(0.98);
}
}