skeletons, tournament stats, polish, bug fixes
This commit is contained in:
@@ -32,17 +32,17 @@ services:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis-cache
|
||||
ports:
|
||||
- "6379:6379"
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
#redis:
|
||||
# image: redis:7-alpine
|
||||
# container_name: redis-cache
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
# command: redis-server --appendonly yes
|
||||
# volumes:
|
||||
# - redis-data:/data
|
||||
# networks:
|
||||
# - app-network
|
||||
# restart: unless-stopped
|
||||
|
||||
supertokens:
|
||||
image: registry.supertokens.io/supertokens/supertokens-postgresql
|
||||
|
||||
@@ -18,7 +18,7 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
|
||||
loader: ({ context }) => ({
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `Manage Teams - ${context.tournament.name}`,
|
||||
title: `${context.tournament.name} Teams`,
|
||||
},
|
||||
withPadding: false,
|
||||
}),
|
||||
|
||||
@@ -27,7 +27,6 @@ export const Route = createFileRoute("/_authed/")({
|
||||
|
||||
function Home() {
|
||||
const { data: tournament } = useCurrentTournament();
|
||||
|
||||
if (!tournament.matches || tournament.matches.length === 0) {
|
||||
return <UpcomingTournament tournament={tournament} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import TeamProfile from "@/features/teams/components/team-profile";
|
||||
import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton";
|
||||
import { teamKeys, teamQueries } from "@/features/teams/queries";
|
||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Suspense } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchSchema = z.object({
|
||||
@@ -24,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
||||
}),
|
||||
component: () => {
|
||||
const { teamId } = Route.useParams();
|
||||
return <TeamProfile id={teamId} />;
|
||||
return <Suspense fallback={<ProfileSkeleton />}>
|
||||
<TeamProfile id={teamId} />
|
||||
</Suspense>;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
|
||||
import Profile from '@/features/tournaments/components/profile';
|
||||
import { z } from "zod";
|
||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||
import { Suspense } from 'react';
|
||||
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
|
||||
|
||||
const searchSchema = z.object({
|
||||
tab: z.string().optional(),
|
||||
@@ -10,9 +12,9 @@ const searchSchema = z.object({
|
||||
|
||||
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
validateSearch: searchSchema,
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
beforeLoad: ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
||||
prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
||||
},
|
||||
loader: ({ params, context }) => ({
|
||||
header: {
|
||||
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
|
||||
function RouteComponent() {
|
||||
const tournamentId = Route.useParams().tournamentId;
|
||||
return <Profile id={tournamentId} />
|
||||
return <Suspense fallback={<ProfileSkeleton />}>
|
||||
<Profile id={tournamentId} />
|
||||
</Suspense>
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import Page from '@/components/page'
|
||||
import { Stack } from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
||||
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { useSheet } from '@/hooks/use-sheet'
|
||||
import Sheet from '@/components/sheet/sheet'
|
||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
||||
import { PlusIcon } from '@phosphor-icons/react'
|
||||
import Button from '@/components/button'
|
||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
||||
import { Suspense } from 'react'
|
||||
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
|
||||
import { Skeleton, Stack } from '@mantine/core'
|
||||
|
||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
const { queryClient } = context;
|
||||
await prefetchServerQuery(queryClient, tournamentQueries.list())
|
||||
prefetchServerQuery(queryClient, tournamentQueries.list())
|
||||
},
|
||||
loader: () => ({
|
||||
header: {
|
||||
@@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { data: tournaments } = useTournaments();
|
||||
const { roles } = useAuth();
|
||||
const sheet = useSheet();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{
|
||||
roles?.includes("Admin") ? (
|
||||
<>
|
||||
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
|
||||
<Sheet {...sheet.props} title='Create Tournament'>
|
||||
<TournamentForm close={sheet.close} />
|
||||
</Sheet>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
tournaments?.map((tournament: any) => (
|
||||
<TournamentCard key={tournament.id} tournament={tournament} />
|
||||
))
|
||||
}
|
||||
</Stack>
|
||||
)
|
||||
return <Suspense fallback={<Stack gap="md">
|
||||
{Array(10).fill(null).map((_, index) => (
|
||||
<Skeleton height="120px" w="100%" />
|
||||
))}
|
||||
</Stack>}>
|
||||
<TournamentCardList />
|
||||
</Suspense>
|
||||
}
|
||||
|
||||
@@ -7,23 +7,22 @@ import {
|
||||
redirect,
|
||||
} from '@tanstack/react-router'
|
||||
import type { ErrorComponentProps } from '@tanstack/react-router'
|
||||
import {
|
||||
Box,
|
||||
import {
|
||||
Box,
|
||||
Button as MantineButton,
|
||||
Text,
|
||||
Title,
|
||||
Stack,
|
||||
Group,
|
||||
Alert,
|
||||
Text,
|
||||
Stack,
|
||||
Group,
|
||||
Collapse,
|
||||
Code,
|
||||
ThemeIcon
|
||||
Container,
|
||||
Center
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useEffect } from 'react'
|
||||
import toast from '@/lib/sonner'
|
||||
import { logger } from '@/lib/logger'
|
||||
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react'
|
||||
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||
import Button from './button'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
@@ -50,112 +49,90 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
|
||||
if (errorMessage.toLowerCase().includes('unauthorized')) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '50vh',
|
||||
padding: 'var(--mantine-spacing-xl)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon color="red" size={80} radius="xl">
|
||||
<XCircleIcon size={48} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} ta="center">Access Denied</Title>
|
||||
<Text size="lg" c="dimmed" ta="center">
|
||||
You don't have permission to access this.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Container size="sm" py="xl">
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xl" fw={600}>Access Denied</Text>
|
||||
<Text c="dimmed" ta="center">
|
||||
You don't have permission to access this page.
|
||||
</Text>
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '50vh',
|
||||
padding: 'var(--mantine-spacing-xl)',
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="lg" maw={600}>
|
||||
<ThemeIcon color="red" size={80} radius="xl">
|
||||
<ExclamationMarkIcon size={48} />
|
||||
</ThemeIcon>
|
||||
|
||||
<Title order={2} ta="center">Something went wrong</Title>
|
||||
|
||||
<Text size="lg" c="dimmed" ta="center">
|
||||
There was an unexpected error. Please try again later.
|
||||
</Text>
|
||||
<Container size="sm" py="xl">
|
||||
<Center>
|
||||
<Stack align="center" gap="md" w="100%">
|
||||
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
title="Error Details"
|
||||
w="100%"
|
||||
>
|
||||
<Text mb="sm">{errorMessage}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={toggleDetails}
|
||||
>
|
||||
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
||||
</Button>
|
||||
<Collapse in={detailsOpened}>
|
||||
<Code block mt="md" p="md">
|
||||
{errorStack}
|
||||
</Code>
|
||||
</Collapse>
|
||||
</Alert>
|
||||
<Text size="xl" fw={600}>Something went wrong</Text>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => router.invalidate()}
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
</MantineButton>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center">
|
||||
An error occurred while loading this page.
|
||||
</Text>
|
||||
|
||||
<Box w="100%" mt="md">
|
||||
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => window.history.back()}
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={toggleDetails}
|
||||
fullWidth
|
||||
>
|
||||
Go Back
|
||||
{detailsOpened ? 'Hide' : 'Show'} details
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Collapse in={detailsOpened}>
|
||||
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||
{errorStack}
|
||||
</Code>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
<Group gap="sm" mt="lg">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => router.invalidate()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
</MantineButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,9 +50,13 @@ const StatItem = ({
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="dimmed">
|
||||
{value !== null ? `${value}${suffix}` : <Skeleton width={20} height={20} />}
|
||||
</Text>
|
||||
{value !== null ? (
|
||||
<Text size="sm" fw={700} c="dimmed">
|
||||
{`${value}${suffix}`}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton width={20} height={20} />
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -101,20 +101,23 @@ function SwipeableTabs({
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(updateHeight, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
});
|
||||
}, [updateHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeSlideRef = slideRefs.current[activeTab];
|
||||
if (!activeSlideRef) return;
|
||||
|
||||
let timeoutId: number;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateHeight();
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(updateHeight, 16);
|
||||
});
|
||||
|
||||
resizeObserver.observe(activeSlideRef);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activeTab, updateHeight]);
|
||||
|
||||
|
||||
@@ -31,14 +31,18 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
pos='relative'
|
||||
h='100dvh'
|
||||
mah='100dvh'
|
||||
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||
style={{
|
||||
top: 0,
|
||||
minHeight: '100dvh',
|
||||
maxHeight: '100dvh'
|
||||
}}
|
||||
>
|
||||
<Header {...header} />
|
||||
<AppShell.Main
|
||||
pos='relative'
|
||||
h='100%'
|
||||
mah='100%'
|
||||
pb={{ base: 70, md: 0 }}
|
||||
pb={{ base: 65, md: 0 }}
|
||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||
maw='100dvw'
|
||||
style={{ transition: 'none', overflow: 'hidden' }}
|
||||
|
||||
@@ -13,7 +13,7 @@ const Navbar = () => {
|
||||
const links = useLinks(user?.id, roles);
|
||||
|
||||
if (isMobile) return (
|
||||
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' bottom='0' style={{ zIndex: 10 }}>
|
||||
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||
{links.map((link) => (
|
||||
<NavLink key={link.href} {...link} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import useRouterConfig from "../hooks/use-router-config";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
|
||||
const THRESHOLD = 80;
|
||||
|
||||
@@ -21,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
const [scrolling, setScrolling] = useState(false);
|
||||
const { refresh } = useRouterConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
||||
|
||||
@@ -79,6 +82,21 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
return () => void ac.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (scrollAreaRef.current) {
|
||||
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
|
||||
if (viewport) {
|
||||
viewport.scrollTop = 0;
|
||||
viewport.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
onScrollPositionChange({ x: 0, y: 0 });
|
||||
}, 10);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [location.pathname, onScrollPositionChange]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -103,6 +121,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
/>
|
||||
</Flex>
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
id='scroll-wrapper'
|
||||
onScrollPositionChange={onScrollPositionChange}
|
||||
type='never' mah='100%' h='100%'
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Alert } from "@mantine/core";
|
||||
import { Info } from "@phosphor-icons/react";
|
||||
import { Transition } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
|
||||
const Error = ({ error }: { error?: string }) => {
|
||||
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
|
||||
|
||||
return (
|
||||
<Transition
|
||||
mounted={show}
|
||||
transition="slide-up"
|
||||
duration={400}
|
||||
timingFunction="ease"
|
||||
>
|
||||
{(styles) => (
|
||||
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
|
||||
)}
|
||||
</Transition>
|
||||
)
|
||||
}
|
||||
|
||||
export default Error;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Text, Group, Stack, Paper, Indicator, Box } from "@mantine/core";
|
||||
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
|
||||
import { CrownIcon } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { Match } from "../types";
|
||||
import Avatar from "@/components/avatar";
|
||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||
import { Suspense } from "react";
|
||||
|
||||
interface MatchCardProps {
|
||||
match: Match;
|
||||
@@ -88,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
<Tooltip
|
||||
label={match.home?.name!}
|
||||
disabled={!match.home?.name}
|
||||
events={{ hover: true, focus: true, touch: true }}
|
||||
>
|
||||
{match.home?.name!}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||
>
|
||||
{match.home?.name!}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Stack gap={1}>
|
||||
{match.home?.players.map((p) => (
|
||||
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
@@ -105,13 +119,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
<Stack gap={1}>
|
||||
{match.home?.players.map((p) => (
|
||||
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -144,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
<Tooltip
|
||||
label={match.away?.name}
|
||||
disabled={!match.away?.name}
|
||||
events={{ hover: true, focus: true, touch: true }}
|
||||
>
|
||||
{match.away?.name}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
lineClamp={1}
|
||||
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||
>
|
||||
{match.away?.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Stack gap={1}>
|
||||
{match.away?.players.map((p) => (
|
||||
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
@@ -161,13 +181,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
<Stack gap={1}>
|
||||
{match.away?.players.map((p) => (
|
||||
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -187,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
}}
|
||||
>
|
||||
<EmojiBar matchId={match.id} />
|
||||
<Suspense>
|
||||
<EmojiBar matchId={match.id} />
|
||||
</Suspense>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Indicator>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Match } from "../types";
|
||||
import MatchCard from "./match-card";
|
||||
|
||||
@@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => {
|
||||
|
||||
return (
|
||||
<Stack p="md" gap="sm">
|
||||
<AnimatePresence>
|
||||
{filteredMatches.map((match, index) => (
|
||||
<motion.div
|
||||
key={`match-${match.id}-${index}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
||||
>
|
||||
<MatchCard match={match} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{filteredMatches.map((match, index) => (
|
||||
<div
|
||||
key={`match-${match.id}-${index}`}
|
||||
>
|
||||
<MatchCard match={match} />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core";
|
||||
|
||||
const HeaderSkeleton = () => {
|
||||
return (
|
||||
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Skeleton opacity={0} height={100} width={100} radius="50%" />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<Skeleton height={24} width={200} />
|
||||
|
||||
@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Avatar name={name} size={100} />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
|
||||
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
|
||||
|
||||
interface DurationPickerProps {
|
||||
songDurationMs: number;
|
||||
@@ -9,6 +9,41 @@ interface DurationPickerProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface IncrementButtonsProps {
|
||||
onAdjust: (seconds: number) => void;
|
||||
disabled: boolean;
|
||||
isPositive?: boolean;
|
||||
}
|
||||
|
||||
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
|
||||
const increments = [1, 5, 30, 60];
|
||||
const labels = ["1s", "5s", "30s", "1m"];
|
||||
|
||||
return (
|
||||
<Group gap={3} wrap="nowrap" flex={1}>
|
||||
{increments.map((increment, index) => (
|
||||
<Button
|
||||
key={increment}
|
||||
variant={isPositive ? "light" : "outline"}
|
||||
color={isPositive ? "blue" : "gray"}
|
||||
size="xs"
|
||||
disabled={disabled}
|
||||
onClick={() => onAdjust(isPositive ? increment : -increment)}
|
||||
flex={1}
|
||||
h={24}
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
minWidth: 0
|
||||
}}
|
||||
>
|
||||
{isPositive ? '+' : '-'}{labels[index]}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const DurationPicker = ({
|
||||
songDurationMs,
|
||||
initialStart = 0,
|
||||
@@ -17,11 +52,6 @@ const DurationPicker = ({
|
||||
disabled = false,
|
||||
}: DurationPickerProps) => {
|
||||
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
||||
const [range, setRange] = useState<[number, number]>([
|
||||
initialStart,
|
||||
initialEnd,
|
||||
]);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -29,7 +59,26 @@ const DurationPicker = ({
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
}, []);
|
||||
|
||||
const validateRange = useCallback(
|
||||
const [startTime, setStartTime] = useState(initialStart);
|
||||
const [endTime, setEndTime] = useState(initialEnd);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [startInputValue, setStartInputValue] = useState(formatTime(initialStart));
|
||||
const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd));
|
||||
|
||||
const parseTimeInput = useCallback((input: string): number | null => {
|
||||
if (input.includes(':')) {
|
||||
const parts = input.split(':');
|
||||
if (parts.length === 2) {
|
||||
const minutes = parseInt(parts[0]) || 0;
|
||||
const seconds = parseInt(parts[1]) || 0;
|
||||
return minutes * 60 + seconds;
|
||||
}
|
||||
}
|
||||
const parsed = parseInt(input);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}, []);
|
||||
|
||||
const validateTimes = useCallback(
|
||||
(start: number, end: number) => {
|
||||
const duration = end - start;
|
||||
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
||||
@@ -53,146 +102,150 @@ const DurationPicker = ({
|
||||
return null;
|
||||
}, [songDurationSeconds]);
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(newRange: [number, number]) => {
|
||||
setRange(newRange);
|
||||
const [start, end] = newRange;
|
||||
const valid = validateRange(start, end);
|
||||
setIsValid(valid);
|
||||
const updateTimes = useCallback((newStart: number, newEnd: number) => {
|
||||
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
|
||||
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
|
||||
|
||||
if (valid) {
|
||||
onChange(start, end);
|
||||
}
|
||||
},
|
||||
[onChange, validateRange]
|
||||
);
|
||||
setStartTime(clampedStart);
|
||||
setEndTime(clampedEnd);
|
||||
setStartInputValue(formatTime(clampedStart));
|
||||
setEndInputValue(formatTime(clampedEnd));
|
||||
|
||||
const handleRangeChangeEnd = useCallback(
|
||||
(newRange: [number, number]) => {
|
||||
let [start, end] = newRange;
|
||||
let duration = end - start;
|
||||
const valid = validateTimes(clampedStart, clampedEnd);
|
||||
setIsValid(valid);
|
||||
|
||||
if (duration < 10) {
|
||||
if (start < songDurationSeconds / 2) {
|
||||
end = Math.min(start + 10, songDurationSeconds);
|
||||
} else {
|
||||
start = Math.max(end - 10, 0);
|
||||
}
|
||||
duration = end - start;
|
||||
}
|
||||
if (valid) {
|
||||
onChange(clampedStart, clampedEnd);
|
||||
}
|
||||
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||
|
||||
if (duration > 15) {
|
||||
const startDiff = Math.abs(start - range[0]);
|
||||
const endDiff = Math.abs(end - range[1]);
|
||||
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setStartInputValue(event.target.value);
|
||||
}, []);
|
||||
|
||||
if (startDiff > endDiff) {
|
||||
end = start + 15;
|
||||
if (end > songDurationSeconds) {
|
||||
end = songDurationSeconds;
|
||||
start = end - 15;
|
||||
}
|
||||
} else {
|
||||
start = end - 15;
|
||||
if (start < 0) {
|
||||
start = 0;
|
||||
end = start + 15;
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEndInputValue(event.target.value);
|
||||
}, []);
|
||||
|
||||
start = Math.max(0, start);
|
||||
end = Math.min(songDurationSeconds, end);
|
||||
const handleStartBlur = useCallback(() => {
|
||||
const parsed = parseTimeInput(startInputValue);
|
||||
if (parsed !== null) {
|
||||
updateTimes(parsed, endTime);
|
||||
} else {
|
||||
setStartInputValue(formatTime(startTime));
|
||||
}
|
||||
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
|
||||
|
||||
const finalRange: [number, number] = [start, end];
|
||||
setRange(finalRange);
|
||||
setIsValid(validateRange(start, end));
|
||||
onChange(start, end);
|
||||
},
|
||||
[range, songDurationSeconds, onChange, validateRange]
|
||||
);
|
||||
const handleEndBlur = useCallback(() => {
|
||||
const parsed = parseTimeInput(endInputValue);
|
||||
if (parsed !== null) {
|
||||
updateTimes(startTime, parsed);
|
||||
} else {
|
||||
setEndInputValue(formatTime(endTime));
|
||||
}
|
||||
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
|
||||
|
||||
const adjustStartTime = useCallback((seconds: number) => {
|
||||
updateTimes(startTime + seconds, endTime);
|
||||
}, [startTime, endTime, updateTimes]);
|
||||
|
||||
const adjustEndTime = useCallback((seconds: number) => {
|
||||
updateTimes(startTime, endTime + seconds);
|
||||
}, [startTime, endTime, updateTimes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validateRange(initialStart, initialEnd)) {
|
||||
if (!validateTimes(initialStart, initialEnd)) {
|
||||
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
||||
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
||||
const defaultRange: [number, number] = [defaultStart, defaultEnd];
|
||||
setRange(defaultRange);
|
||||
onChange(defaultStart, defaultEnd);
|
||||
updateTimes(defaultStart, defaultEnd);
|
||||
}
|
||||
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
|
||||
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
|
||||
|
||||
const segmentDuration = range[1] - range[0];
|
||||
const segmentDuration = endTime - startTime;
|
||||
|
||||
return (
|
||||
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
|
||||
Start and End
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
|
||||
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
|
||||
Walkout Segment ({segmentDuration}s)
|
||||
</Text>
|
||||
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={songDurationSeconds}
|
||||
step={1}
|
||||
value={range}
|
||||
onChange={disabled ? undefined : handleRangeChange}
|
||||
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
||||
marks={[
|
||||
{ value: 0, label: "0:00" },
|
||||
{
|
||||
value: songDurationSeconds,
|
||||
label: formatTime(songDurationSeconds),
|
||||
},
|
||||
]}
|
||||
size="lg"
|
||||
m='xs'
|
||||
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
||||
thumbSize={20}
|
||||
label={disabled ? undefined : (value) => formatTime(value)}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
track: { height: 8 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
Start
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatTime(range[0])}
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||
Start
|
||||
</Text>
|
||||
<TextInput
|
||||
value={startInputValue}
|
||||
onChange={handleStartInputChange}
|
||||
onBlur={handleStartBlur}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
w={70}
|
||||
placeholder="0:00"
|
||||
ta="center"
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 600,
|
||||
fontSize: '12px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<IncrementButtons
|
||||
onAdjust={adjustStartTime}
|
||||
disabled={disabled || startTime <= 0}
|
||||
isPositive={false}
|
||||
/>
|
||||
<IncrementButtons
|
||||
onAdjust={adjustStartTime}
|
||||
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||
isPositive={true}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
Duration
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
||||
{segmentDuration}s
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||
End
|
||||
</Text>
|
||||
<TextInput
|
||||
value={endInputValue}
|
||||
onChange={handleEndInputChange}
|
||||
onBlur={handleEndBlur}
|
||||
disabled={disabled}
|
||||
size="xs"
|
||||
w={70}
|
||||
placeholder="0:15"
|
||||
ta="center"
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 600,
|
||||
fontSize: '12px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<IncrementButtons
|
||||
onAdjust={adjustEndTime}
|
||||
disabled={disabled || endTime <= startTime + 10}
|
||||
isPositive={false}
|
||||
/>
|
||||
<IncrementButtons
|
||||
onAdjust={adjustEndTime}
|
||||
disabled={disabled || endTime >= songDurationSeconds}
|
||||
isPositive={true}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2} align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
End
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatTime(range[1])}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{!isValid && (
|
||||
<Text size="xs" c="red" ta="center">
|
||||
{getValidationMessage(range[0], range[1])}
|
||||
{getValidationMessage(startTime, endTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Song {
|
||||
song_start?: number;
|
||||
song_end?: number;
|
||||
song_image_url: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
interface SongPickerProps {
|
||||
@@ -62,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
||||
}}
|
||||
error={error}
|
||||
Component={SongPickerComponent}
|
||||
componentProps={{ formValues: form.getValues() }}
|
||||
componentProps={{}}
|
||||
title={"Select Song"}
|
||||
label={"Walkout Song"}
|
||||
placeholder={"Select your walkout song"}
|
||||
@@ -73,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
||||
interface SongPickerComponentProps {
|
||||
value: Song | undefined;
|
||||
onChange: (song: Song) => void;
|
||||
formValues: any;
|
||||
}
|
||||
|
||||
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
|
||||
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
|
||||
const handleSongSelect = (track: SpotifyTrack) => {
|
||||
const defaultStart = 0;
|
||||
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
||||
@@ -89,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
||||
song_image_url: track.album.images[0]?.url || '',
|
||||
song_start: defaultStart,
|
||||
song_end: defaultEnd,
|
||||
duration_ms: track.duration_ms,
|
||||
};
|
||||
|
||||
onChange(newSong);
|
||||
@@ -135,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
||||
|
||||
<Stack gap="xs">
|
||||
<DurationPicker
|
||||
songDurationMs={180000}
|
||||
songDurationMs={song?.duration_ms || 180000}
|
||||
initialStart={song?.song_start || 0}
|
||||
initialEnd={song?.song_end || 15}
|
||||
onChange={handleDurationChange}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
|
||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
|
||||
@@ -12,10 +12,11 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const combobox = useCombobox();
|
||||
|
||||
// Standalone search function that doesn't require Spotify context
|
||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
@@ -37,6 +38,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,10 +46,12 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
||||
try {
|
||||
const results = await searchSpotifyTracks(query);
|
||||
setSearchResults(results);
|
||||
combobox.openDropdown();
|
||||
setIsOpen(results.length > 0);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -61,60 +65,117 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
||||
const handleSongSelect = (track: SpotifyTrack) => {
|
||||
onChange(track);
|
||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||
combobox.closeDropdown();
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
const options = searchResults.map((track) => (
|
||||
<Combobox.Option value={track.id} key={track.id}>
|
||||
<Group gap="sm">
|
||||
{track.album.images[2] && (
|
||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{track.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
));
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || searchResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||
handleSongSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={(value) => {
|
||||
const track = searchResults.find(t => t.id === value);
|
||||
if (track) handleSongSelect(track);
|
||||
}}
|
||||
width='100%'
|
||||
zIndex={9999}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
<Box ref={containerRef} pos="relative" w="100%">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (searchResults.length > 0) setIsOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||
/>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{options.length > 0 ? options :
|
||||
<Combobox.Empty>
|
||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||
</Combobox.Empty>
|
||||
}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
{isOpen && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
p={0}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
maxHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y'
|
||||
}}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<Stack gap={0}>
|
||||
{searchResults.map((track, index) => (
|
||||
<Box
|
||||
key={track.id}
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
||||
}}
|
||||
onClick={() => handleSongSelect(track)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{track.album.images[2] && (
|
||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{track.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Flex, Skeleton } from "@mantine/core";
|
||||
|
||||
const HeaderSkeleton = () => {
|
||||
return (
|
||||
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
|
||||
<Skeleton opacity={0} height={200} width={150} />
|
||||
<Flex align='center' justify='center' gap={4} w='100%'>
|
||||
<Skeleton height={36} width={200} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderSkeleton;
|
||||
@@ -11,7 +11,7 @@ interface HeaderProps {
|
||||
const Header = ({ name, logo, id }: HeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<Flex px="xl" w="100%" align="self-end" gap="md">
|
||||
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
|
||||
<Avatar
|
||||
radius="sm"
|
||||
name={name}
|
||||
|
||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Box, Flex, Loader } from "@mantine/core";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import HeaderSkeleton from "./header-skeleton";
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||
<Loader />
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const ProfileSkeleton = () => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
{
|
||||
label: "Tournaments",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Box mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSkeleton;
|
||||
@@ -57,12 +57,12 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
onClick={openEditRules}
|
||||
/>
|
||||
<ListButton
|
||||
label="Edit Enrolled Teams"
|
||||
label="Edit Enrollments"
|
||||
Icon={UsersThreeIcon}
|
||||
onClick={openEditTeams}
|
||||
/>
|
||||
<ListLink
|
||||
label="Manage Teams"
|
||||
label="Manage Team Songs/Logos"
|
||||
Icon={UsersIcon}
|
||||
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Flex, Skeleton } from "@mantine/core";
|
||||
|
||||
const HeaderSkeleton = () => {
|
||||
return (
|
||||
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
|
||||
<Skeleton opacity={0} height={150} width={150} />
|
||||
<Flex align='center' justify='center' gap={4} w='100%'>
|
||||
<Skeleton height={36} width={200} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderSkeleton;
|
||||
@@ -10,7 +10,7 @@ const Header = ({ tournament }: HeaderProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import { useTournament } from "../../queries";
|
||||
import MatchList from "@/features/matches/components/match-list";
|
||||
import { TournamentStats } from "../tournament-stats";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -13,22 +15,22 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
const { data: tournament } = useTournament(id);
|
||||
if (!tournament) return null;
|
||||
|
||||
const tabs = [
|
||||
const tabs = useMemo(() => [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
|
||||
content: <TournamentStats tournament={tournament} />
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
label: "Teams",
|
||||
content: <>
|
||||
<TeamList teams={tournament.teams || []} />
|
||||
</>
|
||||
}
|
||||
];
|
||||
], [tournament]);
|
||||
|
||||
return <>
|
||||
<Header tournament={tournament} />
|
||||
|
||||
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Box, Flex, Loader } from "@mantine/core";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import HeaderSkeleton from "./header-skeleton";
|
||||
|
||||
const SkeletonLoader = () => (
|
||||
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||
<Loader />
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const ProfileSkeleton = () => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <SkeletonLoader />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderSkeleton />
|
||||
<Box mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSkeleton;
|
||||
@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
>
|
||||
{startedMatches.map((match, index) => (
|
||||
<Carousel.Slide key={match.id}>
|
||||
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
|
||||
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
|
||||
<MatchCard match={match} />
|
||||
</Box>
|
||||
</Carousel.Slide>
|
||||
|
||||
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useTournaments } from "../queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import TournamentForm from "./tournament-form";
|
||||
import { TournamentCard } from "./tournament-card";
|
||||
|
||||
const TournamentCardList = () => {
|
||||
const { data: tournaments } = useTournaments();
|
||||
const { roles } = useAuth();
|
||||
const sheet = useSheet();
|
||||
return (
|
||||
<Stack>
|
||||
{roles?.includes("Admin") ? (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<PlusIcon />}
|
||||
variant="subtle"
|
||||
onClick={sheet.open}
|
||||
>
|
||||
Create Tournament
|
||||
</Button>
|
||||
<Sheet {...sheet.props} title="Create Tournament">
|
||||
<TournamentForm close={sheet.close} />
|
||||
</Sheet>
|
||||
</>
|
||||
) : null}
|
||||
{tournaments?.map((tournament: any) => (
|
||||
<TournamentCard key={tournament.id} tournament={tournament} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentCardList;
|
||||
@@ -57,7 +57,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="md" align="center">
|
||||
<Avatar
|
||||
size={120}
|
||||
size={90}
|
||||
radius="sm"
|
||||
name={tournament.name}
|
||||
src={
|
||||
|
||||
285
src/features/tournaments/components/tournament-stats.tsx
Normal file
285
src/features/tournaments/components/tournament-stats.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useMemo, memo } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
UnstyledButton,
|
||||
Container,
|
||||
Box,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
Divider,
|
||||
} from "@mantine/core";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import ListLink from "@/components/list-link";
|
||||
|
||||
interface TournamentStatsProps {
|
||||
tournament: Tournament;
|
||||
}
|
||||
|
||||
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
|
||||
const matches = tournament.matches || [];
|
||||
const nonByeMatches = useMemo(() =>
|
||||
matches.filter((match) => !(match.status === 'tbd' && match.bye === true)),
|
||||
[matches]
|
||||
);
|
||||
const isComplete = useMemo(() =>
|
||||
nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'),
|
||||
[nonByeMatches]
|
||||
);
|
||||
|
||||
const sortedTeamStats = useMemo(() => {
|
||||
return [...(tournament.team_stats || [])].sort((a, b) => {
|
||||
if (b.wins !== a.wins) {
|
||||
return b.wins - a.wins;
|
||||
}
|
||||
return b.total_cups_made - a.total_cups_made;
|
||||
});
|
||||
}, [tournament.team_stats]);
|
||||
|
||||
const renderPodium = () => {
|
||||
if (!isComplete || !tournament.first_place) {
|
||||
return (
|
||||
<Box p="md">
|
||||
<Center>
|
||||
<Text c="dimmed" size="sm">
|
||||
Podium will appear here when the tournament is over
|
||||
</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" px="md">
|
||||
{tournament.first_place && (
|
||||
<Group
|
||||
gap="md"
|
||||
p="md"
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-yellow-light)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
border: '3px solid var(--mantine-color-yellow-outline)',
|
||||
boxShadow: 'var(--mantine-shadow-md)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
|
||||
<CrownIcon size={24} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="md" fw={600}>
|
||||
{tournament.first_place.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{tournament.first_place.players?.map((player) => (
|
||||
<Text key={player.id} size="sm" c="dimmed">
|
||||
{player.first_name} {player.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tournament.second_place && (
|
||||
<Group
|
||||
gap="md"
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-default)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
border: '2px solid var(--mantine-color-default-border)',
|
||||
boxShadow: 'var(--mantine-shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
|
||||
<MedalIcon size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{tournament.second_place.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{tournament.second_place.players?.map((player) => (
|
||||
<Text key={player.id} size="xs" c="dimmed">
|
||||
{player.first_name} {player.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tournament.third_place && (
|
||||
<Group
|
||||
gap="md"
|
||||
p="xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-orange-light)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
border: '2px solid var(--mantine-color-orange-outline)',
|
||||
boxShadow: 'var(--mantine-shadow-sm)',
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
|
||||
<MedalIcon size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{tournament.third_place.name}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{tournament.third_place.players?.map((player) => (
|
||||
<Text key={player.id} size="xs" c="dimmed">
|
||||
{player.first_name} {player.last_name}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const teamStatsWithCalculations = useMemo(() => {
|
||||
return sortedTeamStats.map((stat, index) => ({
|
||||
...stat,
|
||||
index,
|
||||
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
|
||||
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
|
||||
}));
|
||||
}, [sortedTeamStats]);
|
||||
|
||||
const renderTeamStatsTable = () => {
|
||||
if (!teamStatsWithCalculations.length) {
|
||||
return (
|
||||
<Box p="md">
|
||||
<Center>
|
||||
<Text c="dimmed" size="sm">
|
||||
No stats available yet
|
||||
</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text px="md" size="lg" fw={600}>Results</Text>
|
||||
{teamStatsWithCalculations.map((stat) => {
|
||||
return (
|
||||
<Box key={stat.id}>
|
||||
<UnstyledButton
|
||||
w="100%"
|
||||
p="md"
|
||||
style={{
|
||||
borderRadius: 0,
|
||||
transition: "background-color 0.15s ease",
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--mantine-color-gray-0)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" w="100%">
|
||||
<Group gap="sm" align="center">
|
||||
<Avatar name={stat.team_name} size={40} radius="sm" />
|
||||
<Stack gap={2}>
|
||||
<Group gap='xs'>
|
||||
<Text size="xs" c="dimmed">
|
||||
#{stat.index + 1}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{stat.team_name}
|
||||
</Text>
|
||||
{stat.index === 0 && isComplete && (
|
||||
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
|
||||
<CrownIcon size={12} />
|
||||
</ThemeIcon>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md" ta="center">
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
W
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.wins}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
L
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.losses}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
W%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.winPercentage.toFixed(1)}%
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
AVG
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.avgCupsPerMatch.toFixed(1)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
CF
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.total_cups_made}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0}>
|
||||
<Text size="xs" c="dimmed" fw={700}>
|
||||
CA
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stat.total_cups_against}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="100%" px={0}>
|
||||
<Stack gap="md">
|
||||
{renderPodium()}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
Icon={TreeStructureIcon}
|
||||
/>
|
||||
{renderTeamStatsTable()}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
TournamentStats.displayName = 'TournamentStats';
|
||||
@@ -11,10 +11,30 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
||||
|
||||
const copyToClipboard = async (phone: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(phone);
|
||||
toast.success("Phone number copied!");
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(phone);
|
||||
toast.success("Phone number copied!");
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = phone;
|
||||
textArea.style.display = "hidden";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
if (successful) {
|
||||
toast.success("Phone number copied!");
|
||||
} else {
|
||||
throw new Error("Copy command failed");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.success("Failed to copy");
|
||||
console.error("Failed to copy:", err);
|
||||
toast.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</Avatar>
|
||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
||||
<Flex gap="xs" direction="column" justify="space-around">
|
||||
{tournament.location && (
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||
|
||||
@@ -51,8 +51,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
|
||||
<Stack px="md">
|
||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
||||
<Stack px="xs">
|
||||
{tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
|
||||
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -4,37 +4,32 @@ const UpcomingTournamentSkeleton = () => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Flex px="md" justify="center" w="100%">
|
||||
<Skeleton height={240} width={240} radius="md" />
|
||||
<Stack justify="space-between" align="flex-start">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Skeleton height={32} mb="xs" />
|
||||
<Skeleton height={16} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Skeleton height={200} width={240} radius="md" />
|
||||
</Flex>
|
||||
<Stack align="center" gap={2}>
|
||||
<Skeleton height={16} w="30%" mb="md" />
|
||||
<Skeleton height={16} w="30%" />
|
||||
</Stack>
|
||||
|
||||
<Stack px="md">
|
||||
<Skeleton height={14} width="80%" />
|
||||
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Skeleton height={14} width="80%" mb={16} />
|
||||
<Group mb="sm" gap="xs" align="center">
|
||||
<Skeleton height={16} width={16} />
|
||||
<Skeleton height={14} width="20%" />
|
||||
<Skeleton height={32} width={16} />
|
||||
<Skeleton height={32} width="20%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={20} width={80} radius="sm" />
|
||||
<Skeleton height={32} width={80} radius="sm" />
|
||||
</Box>
|
||||
</Group>
|
||||
<Group mb="sm" gap="xs" align="center">
|
||||
<Skeleton height={32} width={16} />
|
||||
<Skeleton height={32} width="20%" />
|
||||
<Box ml="auto">
|
||||
<Skeleton height={32} width={80} radius="sm" />
|
||||
</Box>
|
||||
</Group>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Divider />
|
||||
<Stack gap={0}>
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
<Skeleton height={48} width="100%" />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user