Merge branch 'style' into development
This commit is contained in:
@@ -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<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
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 (
|
||||
<Box
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius:
|
||||
typeof radius === "number"
|
||||
? `${radius + 8}px`
|
||||
: "calc(var(--mantine-radius-md) + 8px)",
|
||||
position: "relative",
|
||||
...(frame && {
|
||||
boxShadow:
|
||||
"0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
|
||||
}),
|
||||
width: "fit-content",
|
||||
padding: FRAME_PADDING,
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
opacity: showGlitch ? 0 : 1,
|
||||
transition: showGlitch
|
||||
? "opacity 0.05s ease-in"
|
||||
: "opacity 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
py={size / 12.5}
|
||||
px={size / 20}
|
||||
bg="var(--mantine-color-default-border)"
|
||||
radius={radius}
|
||||
withBorder={false}
|
||||
style={{
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
name={name}
|
||||
color="initials"
|
||||
size={size}
|
||||
radius={radius}
|
||||
w={size}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: contain ? "contain" : "cover",
|
||||
},
|
||||
}}
|
||||
{src ? (
|
||||
<Box style={{ position: "relative" }}>
|
||||
<img
|
||||
src={src}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MantineAvatar>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{glitchSrc && (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "8px",
|
||||
left: "8px",
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
py={size / 12.5}
|
||||
px={size / 20}
|
||||
bg="var(--mantine-color-default-border)"
|
||||
radius={radius}
|
||||
withBorder={false}
|
||||
alt={name}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
display: "block",
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
borderRadius: innerRadius,
|
||||
opacity: showGlitch ? 0 : 1,
|
||||
transition: showGlitch
|
||||
? "opacity 0.05s ease-in"
|
||||
: "opacity 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
/>
|
||||
{glitchSrc && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={glitchSrc}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
objectFit: contain ? "contain" : "cover",
|
||||
borderRadius: typeof radius === "number" ? `${radius}px` : radius,
|
||||
display: "block",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
borderRadius: innerRadius,
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
muted
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
name={name}
|
||||
color="initials"
|
||||
size={size}
|
||||
radius={radius}
|
||||
w={size}
|
||||
>
|
||||
{children}
|
||||
</MantineAvatar>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -7,3 +7,19 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ const Header = ({ collapsed, title, withBackButton }: HeaderConfig) => {
|
||||
<AppShell.Header
|
||||
id='app-header'
|
||||
display={collapsed ? 'none' : 'flex'}
|
||||
withBorder={false}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'border-color 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{ withBackButton && <BackButton /> }
|
||||
|
||||
@@ -37,8 +37,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
<Stack align='center' gap='xs' mb='md'>
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
frame
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
@@ -51,8 +49,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
radius="md"
|
||||
size={250}
|
||||
px="xs"
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</GlitchAvatar>
|
||||
|
||||
@@ -37,7 +37,7 @@ const EmojiPicker = ({
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom"
|
||||
position="top-end"
|
||||
withArrow
|
||||
shadow="sm"
|
||||
opened={opened}
|
||||
@@ -45,6 +45,7 @@ const EmojiPicker = ({
|
||||
trapFocus
|
||||
closeOnEscape
|
||||
closeOnClickOutside
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.indicators.indicators {
|
||||
position: static;
|
||||
transform: none;
|
||||
justify-content: center;
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.indicator.indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.indicator.indicator[data-active] {
|
||||
width: 16px;
|
||||
background-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.indicator.indicator {
|
||||
transition: background-color 150ms ease-out, width 150ms ease-out;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
<Stack px="sm" align="center" gap={0}>
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
@@ -27,8 +26,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
}
|
||||
radius="md"
|
||||
size={250}
|
||||
px="xs"
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</GlitchAvatar>
|
||||
|
||||
@@ -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 ? (
|
||||
<Box>
|
||||
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap">
|
||||
<Indicator
|
||||
size={8}
|
||||
color="red"
|
||||
processing
|
||||
position="middle-start"
|
||||
offset={0}
|
||||
/>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
|
||||
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Carousel
|
||||
slideSize="95%"
|
||||
slideSize="90%"
|
||||
slideGap="xs"
|
||||
withControls={false}
|
||||
withIndicators={startedMatches.length > 1}
|
||||
classNames={{
|
||||
indicators: carouselClasses.indicators,
|
||||
indicator: carouselClasses.indicator,
|
||||
}}
|
||||
>
|
||||
{startedMatches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
|
||||
@@ -4,14 +4,9 @@ const StartedTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header skeleton */}
|
||||
<Stack px="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Skeleton height={32} width="60%" mb="xs" />
|
||||
<Skeleton height={16} width="40%" />
|
||||
</Box>
|
||||
<Skeleton height={60} width={60} radius="md" />
|
||||
</Group>
|
||||
<Stack px="md" align="center" gap="xs">
|
||||
<Skeleton height={268} width={268} radius="lg" />
|
||||
<Skeleton height={16} width="55%" />
|
||||
</Stack>
|
||||
|
||||
{/* Match carousel skeleton */}
|
||||
|
||||
@@ -19,7 +19,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
<Stack align="center" gap={16}>
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
@@ -32,8 +31,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
}
|
||||
radius="md"
|
||||
size={300}
|
||||
px="xs"
|
||||
withBorder={false}
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</GlitchAvatar>
|
||||
|
||||
@@ -57,12 +57,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{
|
||||
borderRadius:
|
||||
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||
borderTop: "3px solid var(--mantine-primary-color-filled)",
|
||||
backgroundImage:
|
||||
"linear-gradient(to bottom, var(--mantine-primary-color-light), transparent 110px)",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -4,7 +4,7 @@ const UpcomingTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Flex px="md" justify="center" w="100%">
|
||||
<Skeleton height={200} width={240} radius="md" />
|
||||
<Skeleton height={318} width={318} radius="lg" />
|
||||
</Flex>
|
||||
<Stack align="center" gap={2}>
|
||||
<Skeleton height={16} w="30%" mb="md" />
|
||||
@@ -12,7 +12,14 @@ const UpcomingTournamentSkeleton = () => {
|
||||
</Stack>
|
||||
|
||||
<Stack px="md">
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Card
|
||||
withBorder
|
||||
p="lg"
|
||||
style={{
|
||||
borderRadius:
|
||||
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={14} width="80%" mb={16} />
|
||||
<Group mb="sm" gap="xs" align="center">
|
||||
<Skeleton height={32} width={16} />
|
||||
|
||||
Reference in New Issue
Block a user