styling
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user