Merge branch 'style' into development

This commit is contained in:
yohlo
2026-07-14 00:12:46 -07:00
17 changed files with 504 additions and 168 deletions
+53 -90
View File
@@ -1,36 +1,27 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { Paper, Box } from "@mantine/core"; import { Box, Avatar as MantineAvatar } from "@mantine/core";
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
} from "@mantine/core";
interface GlitchAvatarProps interface GlitchAvatarProps {
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string; name: string;
src?: string; src?: string;
glitchSrc?: string; glitchSrc?: string;
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean;
contain?: boolean;
children?: React.ReactNode; 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 = ({ const GlitchAvatar = ({
name, name,
src, src,
glitchSrc, glitchSrc,
size = 35, size = 35,
radius = "100%", radius = "md",
withBorder = true,
contain = false,
children, children,
px,
frame = false,
...props
}: GlitchAvatarProps) => { }: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false); const [showGlitch, setShowGlitch] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -90,98 +81,70 @@ const GlitchAvatar = ({
}); });
}, [showGlitch, isPlaying]); }, [showGlitch, isPlaying]);
const innerRadius = toCssRadius(radius);
return ( return (
<Box <Box
style={{ style={{
padding: "8px",
borderRadius:
typeof radius === "number"
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative", position: "relative",
...(frame && { width: "fit-content",
boxShadow: padding: FRAME_PADDING,
"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)", border: "1px solid var(--mantine-color-default-border)",
}), borderRadius: `calc(${innerRadius} + ${FRAME_PADDING}px)`,
}} }}
> >
<Box {src ? (
style={{ <Box style={{ position: "relative" }}>
opacity: showGlitch ? 0 : 1, <img
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={src} src={src}
{...props} alt={name}
>
{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}
style={{ 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 <video
ref={videoRef} ref={videoRef}
src={glitchSrc} src={glitchSrc}
style={{ style={{
width: `${size}px`, position: "absolute",
height: `${size}px`, inset: 0,
objectFit: contain ? "contain" : "cover", width: "100%",
borderRadius: typeof radius === "number" ? `${radius}px` : radius, height: "100%",
display: "block", objectFit: "contain",
borderRadius: innerRadius,
opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden",
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
pointerEvents: "none",
}} }}
muted muted
playsInline playsInline
preload="auto" preload="auto"
/> />
</Paper> )}
</Box> </Box>
) : (
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
>
{children}
</MantineAvatar>
)} )}
</Box> </Box>
); );
@@ -1,7 +1,8 @@
import React, { useMemo } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, ScrollArea } from "@mantine/core"; import { Text, ScrollArea, Box } from "@mantine/core";
import { BracketData } from "../types"; import { BracketData } from "../types";
import { Bracket } from "./bracket"; import { Bracket } from "./bracket";
import MatchDock from "./match-dock";
import useAppShellHeight from "@/hooks/use-appshell-height"; import useAppShellHeight from "@/hooks/use-appshell-height";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
@@ -17,6 +18,8 @@ interface BracketViewProps {
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => { const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
const height = useAppShellHeight(); const height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false);
const orders = useMemo(() => { const orders = useMemo(() => {
const map: Record<number, number> = {}; const map: Record<number, number> = {};
bracket.winners.flat().forEach(match => map[match.lid] = match.order); bracket.winners.flat().forEach(match => map[match.lid] = match.order);
@@ -24,7 +27,60 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
return map; return map;
}, [bracket.winners, bracket.losers]); }, [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})`} h={`calc(${height})`}
className={styles["bracket-container"]} className={styles["bracket-container"]}
style={{ style={{
@@ -37,17 +93,19 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Winners Bracket Winners Bracket
</Text> </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> </div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && ( {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div> <div>
<Text fw={600} size="md" m={16}> <Text fw={600} size="md" m={16}>
Losers Bracket Losers Bracket
</Text> </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> </div>
)} )}
</ScrollArea> </ScrollArea>
<MatchDock match={selectedMatch} onClose={closeDock} />
</Box>
}; };
export default BracketView; export default BracketView;
@@ -11,6 +11,8 @@ interface BracketProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null;
} }
export const Bracket: React.FC<BracketProps> = ({ export const Bracket: React.FC<BracketProps> = ({
@@ -18,6 +20,8 @@ export const Bracket: React.FC<BracketProps> = ({
orders, orders,
showControls, showControls,
groupConfig, groupConfig,
onMatchTap,
selectedMatchLid,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
@@ -138,6 +142,8 @@ export const Bracket: React.FC<BracketProps> = ({
orders={orders} orders={orders}
showControls={showControls} showControls={showControls}
groupConfig={groupConfig} groupConfig={groupConfig}
onTap={onMatchTap}
selected={selectedMatchLid === match.lid}
/> />
</div> </div>
) )
+37 -45
View File
@@ -12,6 +12,8 @@ import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries"; import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks"; import { useSpotifyPlayback } from "@/lib/spotify/hooks";
import { getGroupLabel } from "../utils/group-label";
import styles from "./styles.module.css";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -21,6 +23,8 @@ interface MatchCardProps {
num_groups: number; num_groups: number;
advance_per_group: number; advance_per_group: number;
}; };
onTap?: (match: Match) => void;
selected?: boolean;
} }
export const MatchCard: React.FC<MatchCardProps> = ({ export const MatchCard: React.FC<MatchCardProps> = ({
@@ -28,50 +32,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
orders, orders,
showControls, showControls,
groupConfig, groupConfig,
onTap,
selected,
}) => { }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const editSheet = useSheet(); const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback(); const { playTrack, pause } = useSpotifyPlayback();
const getGroupLabel = useCallback((seed: number | undefined) => { const canTap = !!(onTap && match.home && match.away);
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 homeSlot = useMemo( const homeSlot = useMemo(
() => ({ () => ({
@@ -85,9 +53,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.home_cups !== undefined && match.home_cups !== undefined &&
match.away_cups !== undefined && match.away_cups !== undefined &&
match.home_cups > match.away_cups, 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( const awaySlot = useMemo(
() => ({ () => ({
@@ -101,9 +69,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match.away_cups !== undefined && match.away_cups !== undefined &&
match.home_cups !== undefined && match.home_cups !== undefined &&
match.away_cups > match.home_cups, 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( const showToolbar = useMemo(
@@ -273,10 +241,31 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220} w={showToolbar || showEditButton ? 200 : 220}
withBorder withBorder
pos="relative" 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={{ style={{
overflow: "visible", overflow: "visible",
backgroundColor: 'var(--mantine-color-body)', 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)', boxShadow: 'var(--mantine-shadow-sm)',
}} }}
data-match-lid={match.lid} data-match-lid={match.lid}
@@ -310,7 +299,10 @@ export const MatchCard: React.FC<MatchCardProps> = ({
size="sm" size="sm"
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={handleSpeakerClick} onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup" aria-label="Announce matchup"
> >
<SpeakerHighIcon size={12} /> <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); 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 <AppShell.Header
id='app-header' id='app-header'
display={collapsed ? 'none' : 'flex'} display={collapsed ? 'none' : 'flex'}
withBorder={false}
style={{ style={{
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
transition: 'border-color 200ms ease-out',
}} }}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
-4
View File
@@ -37,8 +37,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
<Stack align='center' gap='xs' mb='md'> <Stack align='center' gap='xs' mb='md'>
<GlitchAvatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain
frame
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -51,8 +49,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
} }
radius="md" radius="md"
size={250} size={250}
px="xs"
withBorder={false}
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</GlitchAvatar> </GlitchAvatar>
@@ -37,7 +37,7 @@ const EmojiPicker = ({
return ( return (
<Popover <Popover
position="bottom" position="top-end"
withArrow withArrow
shadow="sm" shadow="sm"
opened={opened} opened={opened}
@@ -45,6 +45,7 @@ const EmojiPicker = ({
trapFocus trapFocus
closeOnEscape closeOnEscape
closeOnClickOutside closeOnClickOutside
withinPortal
> >
<Popover.Target> <Popover.Target>
<ActionIcon <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}> <Stack px="sm" align="center" gap={0}>
<GlitchAvatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -27,8 +26,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
} }
radius="md" radius="md"
size={250} size={250}
px="xs"
withBorder={false}
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</GlitchAvatar> </GlitchAvatar>
@@ -1,8 +1,9 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Tournament } from "../../types"; import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context"; 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 { Carousel } from "@mantine/carousel";
import carouselClasses from "./carousel.module.css";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react"; import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
import TeamListButton from "../upcoming-tournament/team-list-button"; import TeamListButton from "../upcoming-tournament/team-list-button";
@@ -47,10 +48,27 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
{startedMatches.length > 0 ? ( {startedMatches.length > 0 ? (
<Box> <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 <Carousel
slideSize="95%" slideSize="90%"
slideGap="xs" slideGap="xs"
withControls={false} withControls={false}
withIndicators={startedMatches.length > 1}
classNames={{
indicators: carouselClasses.indicators,
indicator: carouselClasses.indicator,
}}
> >
{startedMatches.map((match, index) => ( {startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}> <Carousel.Slide key={match.id}>
@@ -4,14 +4,9 @@ const StartedTournamentSkeleton = () => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{/* Header skeleton */} {/* Header skeleton */}
<Stack px="md"> <Stack px="md" align="center" gap="xs">
<Group justify="space-between" align="flex-start"> <Skeleton height={268} width={268} radius="lg" />
<Box style={{ flex: 1 }}> <Skeleton height={16} width="55%" />
<Skeleton height={32} width="60%" mb="xs" />
<Skeleton height={16} width="40%" />
</Box>
<Skeleton height={60} width={60} radius="md" />
</Group>
</Stack> </Stack>
{/* Match carousel skeleton */} {/* Match carousel skeleton */}
@@ -19,7 +19,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
<Stack align="center" gap={16}> <Stack align="center" gap={16}>
<GlitchAvatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -32,8 +31,6 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
} }
radius="md" radius="md"
size={300} size={300}
px="xs"
withBorder={false}
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</GlitchAvatar> </GlitchAvatar>
@@ -57,12 +57,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Card <Card
withBorder withBorder
radius="lg"
p="lg" p="lg"
style={{ style={{
borderRadius:
"2px 2px var(--mantine-radius-lg) var(--mantine-radius-lg)",
borderTop: "3px solid var(--mantine-primary-color-filled)", borderTop: "3px solid var(--mantine-primary-color-filled)",
backgroundImage:
"linear-gradient(to bottom, var(--mantine-primary-color-light), transparent 110px)",
}} }}
> >
<Stack gap="xs"> <Stack gap="xs">
@@ -4,7 +4,7 @@ const UpcomingTournamentSkeleton = () => {
return ( return (
<Stack gap="lg"> <Stack gap="lg">
<Flex px="md" justify="center" w="100%"> <Flex px="md" justify="center" w="100%">
<Skeleton height={200} width={240} radius="md" /> <Skeleton height={318} width={318} radius="lg" />
</Flex> </Flex>
<Stack align="center" gap={2}> <Stack align="center" gap={2}>
<Skeleton height={16} w="30%" mb="md" /> <Skeleton height={16} w="30%" mb="md" />
@@ -12,7 +12,14 @@ const UpcomingTournamentSkeleton = () => {
</Stack> </Stack>
<Stack px="md"> <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} /> <Skeleton height={14} width="80%" mb={16} />
<Group mb="sm" gap="xs" align="center"> <Group mb="sm" gap="xs" align="center">
<Skeleton height={32} width={16} /> <Skeleton height={32} width={16} />