diff --git a/src/components/glitch-avatar.tsx b/src/components/glitch-avatar.tsx index d435bd8..cb9b7cc 100644 --- a/src/components/glitch-avatar.tsx +++ b/src/components/glitch-avatar.tsx @@ -1,36 +1,27 @@ import { useState, useEffect, useRef } from "react"; -import { Paper, Box } from "@mantine/core"; -import { - Avatar as MantineAvatar, - AvatarProps as MantineAvatarProps, -} from "@mantine/core"; +import { Box, Avatar as MantineAvatar } from "@mantine/core"; -interface GlitchAvatarProps - extends Omit { +interface GlitchAvatarProps { name: string; src?: string; glitchSrc?: string; size?: number; radius?: string | number; - withBorder?: boolean; - contain?: boolean; children?: React.ReactNode; - px?: string | number; - frame?: boolean; } +const FRAME_PADDING = 8; + +const toCssRadius = (radius: string | number) => + typeof radius === "number" ? `${radius}px` : `var(--mantine-radius-${radius})`; + const GlitchAvatar = ({ name, src, glitchSrc, size = 35, - radius = "100%", - withBorder = true, - contain = false, + radius = "md", children, - px, - frame = false, - ...props }: GlitchAvatarProps) => { const [showGlitch, setShowGlitch] = useState(false); const [isPlaying, setIsPlaying] = useState(false); @@ -90,98 +81,70 @@ const GlitchAvatar = ({ }); }, [showGlitch, isPlaying]); + const innerRadius = toCssRadius(radius); + return ( - - - + - {children} - - - - - {glitchSrc && ( - - + /> + {glitchSrc && ( + )} + ) : ( + + {children} + )} ); diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 6920fc7..8563317 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -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 = ({ bracket, showControls, groupConfig }) => { const height = useAppShellHeight(); + const viewportRef = useRef(null); + const hasAutoScrolled = useRef(false); const orders = useMemo(() => { const map: Record = {}; bracket.winners.flat().forEach(match => map[match.lid] = match.order); @@ -24,7 +27,60 @@ const BracketView: React.FC = ({ bracket, showControls, groupC return map; }, [bracket.winners, bracket.losers]); - return (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 + = ({ bracket, showControls, groupC Winners Bracket - + {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
Losers Bracket - +
)}
+ +
}; export default BracketView; diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx index e038482..484dca8 100644 --- a/src/features/bracket/components/bracket.tsx +++ b/src/features/bracket/components/bracket.tsx @@ -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 = ({ @@ -18,6 +20,8 @@ export const Bracket: React.FC = ({ orders, showControls, groupConfig, + onMatchTap, + selectedMatchLid, }) => { const containerRef = useRef(null); const svgRef = useRef(null); @@ -138,6 +142,8 @@ export const Bracket: React.FC = ({ orders={orders} showControls={showControls} groupConfig={groupConfig} + onTap={onMatchTap} + selected={selectedMatchLid === match.lid} /> ) diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx index 65f49e1..f833327 100644 --- a/src/features/bracket/components/match-card.tsx +++ b/src/features/bracket/components/match-card.tsx @@ -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 = ({ @@ -28,50 +32,14 @@ export const MatchCard: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ size="sm" variant="subtle" color="gray" - onClick={handleSpeakerClick} + onClick={(e) => { + e.stopPropagation(); + handleSpeakerClick(); + }} aria-label="Announce matchup" > diff --git a/src/features/bracket/components/match-dock.tsx b/src/features/bracket/components/match-dock.tsx new file mode 100644 index 0000000..dc0ba70 --- /dev/null +++ b/src/features/bracket/components/match-dock.tsx @@ -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; + cups: number; + isWinner: boolean; + isRegional: boolean; + ended: boolean; +}) => { + const navigate = useNavigate(); + + return ( + + navigate({ to: `/teams/${team.id}` })} + > + + + {team.name} + + + {ended && ( + + )} + + ); +}; + +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 ( + <> + + {match && match.home && match.away && ( + + + + + + {started && ( + + )} + + Match {match.order} · Round {match.round + 1} + {match.is_losers_bracket && " (Losers)"} + + + + + + match.away_cups} + isRegional={match.tournament.regional === true} + ended={ended} + /> + + match.home_cups} + isRegional={match.tournament.regional === true} + ended={ended} + /> + + + + + + + } + > + + + + {!hasPrivate && ( + + + + + + + + + )} + + + + + )} + + + {match?.home && match?.away && h2hSheet.isOpen && ( + + + + )} + + ); +}; + +export default MatchDock; diff --git a/src/features/bracket/components/styles.module.css b/src/features/bracket/components/styles.module.css index 21a150c..5ea4ee4 100644 --- a/src/features/bracket/components/styles.module.css +++ b/src/features/bracket/components/styles.module.css @@ -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); + } } \ No newline at end of file diff --git a/src/features/bracket/utils/group-label.ts b/src/features/bracket/utils/group-label.ts new file mode 100644 index 0000000..a1f120f --- /dev/null +++ b/src/features/bracket/utils/group-label.ts @@ -0,0 +1,49 @@ +export interface GroupConfig { + num_groups: number; + advance_per_group: number; +} + +export function getGroupLabel( + seed: number | undefined, + groupConfig: GroupConfig | undefined +): string | 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}`; + } +} diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx index 22a8125..a0ff0ab 100644 --- a/src/features/core/components/header.tsx +++ b/src/features/core/components/header.tsx @@ -7,11 +7,9 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => { { withBackButton && } diff --git a/src/features/login/components/layout.tsx b/src/features/login/components/layout.tsx index 2d359d2..18f3ce9 100644 --- a/src/features/login/components/layout.tsx +++ b/src/features/login/components/layout.tsx @@ -37,8 +37,6 @@ const Layout: React.FC = ({ children }) => { = ({ children }) => { } radius="md" size={250} - px="xs" - withBorder={false} > diff --git a/src/features/reactions/components/emoji-picker.tsx b/src/features/reactions/components/emoji-picker.tsx index 74eaf46..779b7bc 100644 --- a/src/features/reactions/components/emoji-picker.tsx +++ b/src/features/reactions/components/emoji-picker.tsx @@ -37,7 +37,7 @@ const EmojiPicker = ({ return ( { { } radius="md" size={250} - px="xs" - withBorder={false} > diff --git a/src/features/tournaments/components/started-tournament/index.tsx b/src/features/tournaments/components/started-tournament/index.tsx index 7a3489a..9de9b25 100644 --- a/src/features/tournaments/components/started-tournament/index.tsx +++ b/src/features/tournaments/components/started-tournament/index.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; import { Tournament } from "../../types"; import { useAuth } from "@/contexts/auth-context"; -import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core"; +import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core"; import { Carousel } from "@mantine/carousel"; +import carouselClasses from "./carousel.module.css"; import ListLink from "@/components/list-link"; import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react"; import TeamListButton from "../upcoming-tournament/team-list-button"; @@ -47,10 +48,27 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({ {startedMatches.length > 0 ? ( + + + + Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`} + + 1} + classNames={{ + indicators: carouselClasses.indicators, + indicator: carouselClasses.indicator, + }} > {startedMatches.map((match, index) => ( diff --git a/src/features/tournaments/components/started-tournament/skeleton.tsx b/src/features/tournaments/components/started-tournament/skeleton.tsx index 578620b..12cfa81 100644 --- a/src/features/tournaments/components/started-tournament/skeleton.tsx +++ b/src/features/tournaments/components/started-tournament/skeleton.tsx @@ -4,14 +4,9 @@ const StartedTournamentSkeleton = () => { return ( {/* Header skeleton */} - - - - - - - - + + + {/* Match carousel skeleton */} diff --git a/src/features/tournaments/components/upcoming-tournament/header.tsx b/src/features/tournaments/components/upcoming-tournament/header.tsx index f73425d..295f970 100644 --- a/src/features/tournaments/components/upcoming-tournament/header.tsx +++ b/src/features/tournaments/components/upcoming-tournament/header.tsx @@ -19,7 +19,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => { { } radius="md" size={300} - px="xs" - withBorder={false} > diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index 334bb33..8923bd0 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -57,12 +57,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ diff --git a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx index 05d6e16..6ecf3b9 100644 --- a/src/features/tournaments/components/upcoming-tournament/skeleton.tsx +++ b/src/features/tournaments/components/upcoming-tournament/skeleton.tsx @@ -4,7 +4,7 @@ const UpcomingTournamentSkeleton = () => { return ( - + @@ -12,7 +12,14 @@ const UpcomingTournamentSkeleton = () => { - +