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
+53 -90
View File
@@ -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>
)
+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);
}
}
+49
View File
@@ -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}`;
}
}
-2
View File
@@ -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 /> }
-4
View File
@@ -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} />