Merge branch 'style' into development

This commit is contained in:
yohlo
2026-07-12 21:30:02 -07:00
72 changed files with 1547 additions and 472 deletions
+2 -2
View File
@@ -18,8 +18,8 @@
], ],
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"theme_color": "#1e293b", "theme_color": "#242424",
"background_color": "#0f172a", "background_color": "#242424",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/",
"categories": ["games", "social", "beer pong"], "categories": ["games", "social", "beer pong"],
+24
View File
@@ -28,4 +28,28 @@
[data-drawer-level="3"].drawer-scaling { [data-drawer-level="3"].drawer-scaling {
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
}
@media (prefers-reduced-motion: reduce) {
.app,
[data-drawer-level] {
transition: none;
}
.app.drawer-scaling,
[data-drawer-level].drawer-scaling {
transform: none;
border-radius: 0;
}
[data-vaul-drawer],
[data-vaul-overlay] {
animation: none !important;
transition: none !important;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(0deg); }
}
} }
+1 -1
View File
@@ -32,7 +32,7 @@ export function getRouter() {
defaultPreloadStaleTime: 60_000, defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true, scrollRestoration: true,
defaultViewTransition: false, defaultViewTransition: true,
}); });
setupRouterSsrQueryIntegration({ setupRouterSsrQueryIntegration({
+12 -2
View File
@@ -19,6 +19,7 @@ import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
@@ -41,11 +42,10 @@ export const Route = createRootRouteWithContext<{
{ {
name: "viewport", name: "viewport",
content: content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content", "width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=resizes-content, viewport-fit=cover",
}, },
{ name: 'description', content: 'Amicus meus madidus' }, { name: 'description', content: 'Amicus meus madidus' },
{ name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' }, { name: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
{ name: 'theme-color', content: '#1e293b' },
{ property: 'og:title', content: 'FLXN' }, { property: 'og:title', content: 'FLXN' },
{ property: 'og:description', content: 'Amicus meus madidus' }, { property: 'og:description', content: 'Amicus meus madidus' },
{ property: 'og:url', content: 'https://flexxon.app' }, { property: 'og:url', content: 'https://flexxon.app' },
@@ -182,6 +182,16 @@ function RootDocument({ children }: { children: React.ReactNode }) {
> >
<head> <head>
<HeadContent /> <HeadContent />
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content={CHROME_COLORS.light.base}
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content={CHROME_COLORS.dark.base}
/>
<ColorSchemeScript /> <ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
</head> </head>
+16 -4
View File
@@ -1,7 +1,7 @@
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router"; import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout"; import Layout from "@/features/core/components/layout";
import { useServerEvents } from "@/hooks/use-server-events"; import { useServerEvents } from "@/hooks/use-server-events";
import { Flex, Loader } from "@mantine/core"; import { Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed")({ export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
@@ -26,9 +26,21 @@ export const Route = createFileRoute("/_authed")({
}, },
pendingComponent: () => ( pendingComponent: () => (
<Layout> <Layout>
<Flex w='100%' h="40dvh" justify="center" align="flex-end"> <Stack gap="md" p="md" w="100%">
<Loader size='xl' /> <Group gap="sm">
</Flex> <Skeleton height={40} width={40} radius="sm" />
<Skeleton height={24} width="45%" radius="sm" />
</Group>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`authed-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.18, 0.3) }}
/>
))}
</Stack>
</Layout> </Layout>
), ),
}); });
+32 -4
View File
@@ -2,8 +2,8 @@ import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities"; import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players"; import { PlayersActivityTable, playerQueries } from "@/features/players";
import { Tabs } from "@mantine/core"; import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
import { useState } from "react"; import { Suspense, useState } from "react";
export const Route = createFileRoute("/_authed/admin/activities")({ export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats, component: Stats,
@@ -23,6 +23,30 @@ export const Route = createFileRoute("/_authed/admin/activities")({
}), }),
}); });
function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
return (
<Stack gap={0}>
{withSearch && (
<Box px="md" pb="sm">
<Skeleton height={42} radius="sm" />
</Box>
)}
{Array.from({ length: 8 }).map((_, index) => (
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
<Stack gap={6}>
<Skeleton height={14} width={160} radius="sm" />
<Skeleton height={10} width={100} radius="sm" />
</Stack>
<Skeleton height={10} width={60} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function Stats() { function Stats() {
const [activeTab, setActiveTab] = useState<string | null>("server-functions"); const [activeTab, setActiveTab] = useState<string | null>("server-functions");
@@ -34,11 +58,15 @@ function Stats() {
</Tabs.List> </Tabs.List>
<Tabs.Panel value="server-functions"> <Tabs.Panel value="server-functions">
<ActivitiesTable /> <Suspense fallback={<ActivityRowsSkeleton withSearch />}>
<ActivitiesTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="player-activity"> <Tabs.Panel value="player-activity">
<PlayersActivityTable /> <Suspense fallback={<ActivityRowsSkeleton />}>
<PlayersActivityTable />
</Suspense>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
); );
@@ -1,7 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core"; import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react"; import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments"; import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments";
@@ -27,8 +27,23 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-part
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: AssignPartnersPending,
}); });
function AssignPartnersPending() {
return (
<Stack gap="lg">
<Stack gap="xs">
<Group gap="xs" align="baseline">
<Skeleton height={28} width={36} radius="sm" />
<Skeleton height={14} width={110} radius="sm" />
</Group>
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
</Stack>
</Stack>
);
}
interface TeamAssignment { interface TeamAssignment {
player1: PlayerInfo; player1: PlayerInfo;
player2: PlayerInfo; player2: PlayerInfo;
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament"; import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTournamentPending,
}); });
function ManageTournamentPending() {
return (
<Stack gap={0}>
{Array.from({ length: 5 }).map((_, index) => (
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={20} width={20} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
return <ManageTournament tournamentId={id} />; return <ManageTournament tournamentId={id} />;
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams"; import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,37 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
withPadding: false, withPadding: false,
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: ManageTeamsPending,
}); });
function ManageTeamsPending() {
return (
<Stack gap="xs">
<Box px="md">
<Skeleton height={42} radius="sm" />
</Box>
<Box px="md">
<Skeleton height={12} width={90} radius="sm" />
</Box>
<Stack gap={0}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
<Group p="xs" wrap="nowrap" w="100%">
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="45%" radius="sm" />
<Stack ml="auto" gap={6} align="flex-end">
<Skeleton height={10} width={90} radius="sm" />
<Skeleton height={10} width={70} radius="sm" />
</Stack>
</Group>
<Divider />
</div>
))}
</Stack>
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { tournament } = Route.useRouteContext(); const { tournament } = Route.useRouteContext();
@@ -1,12 +1,14 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments"; import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries"; import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
export const Route = createFileRoute("/_authed/admin/tournaments/")({ export const Route = createFileRoute("/_authed/admin/tournaments/")({
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.list()); prefetchServerQuery(queryClient, tournamentQueries.list());
}, },
loader: () => ({ loader: () => ({
header: { header: {
@@ -19,6 +21,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent, component: RouteComponent,
}); });
function TournamentListSkeleton() {
return (
<Stack gap={0}>
{Array.from({ length: 6 }).map((_, index) => (
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={16} width="55%" radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</div>
))}
</Stack>
);
}
function RouteComponent() { function RouteComponent() {
return <ManageTournaments />; return (
<Suspense fallback={<TournamentListSkeleton />}>
<ManageTournaments />
</Suspense>
);
} }
@@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title } from "@mantine/core"; import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
@@ -37,8 +37,46 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: RunTournamentPending,
}); });
function RunTournamentPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`run-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`run-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
+1 -1
View File
@@ -64,7 +64,7 @@ function Stats() {
</Button> </Button>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}> <Suspense fallback={<PlayerStatsTableSkeleton hideFilters />}>
<PlayerStatsTable viewType={deferredViewType} /> <PlayerStatsTable viewType={deferredViewType} />
</Suspense> </Suspense>
</Box> </Box>
@@ -4,7 +4,7 @@ import {
useTournament, useTournament,
} from "@/features/tournaments/queries"; } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { Container } from "@mantine/core"; import { Box, Container, Flex, Skeleton, Stack } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { BracketData } from "@/features/bracket/types"; import { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
@@ -31,8 +31,49 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: BracketPending,
}); });
function BracketPending() {
const columns = [4, 2, 1];
return (
<Container size="md" px={0}>
<Box
p={0}
style={{
overflow: "hidden",
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
minHeight: "70dvh",
}}
>
<Skeleton height={18} width={140} radius="sm" m={16} />
<Flex gap="xl" px={16} align="stretch">
{columns.map((count, columnIndex) => (
<Stack
key={`bracket-pending-round-${columnIndex}`}
gap="xl"
justify="space-around"
style={{ opacity: 1 - columnIndex * 0.25 }}
>
{Array.from({ length: count }).map((_, matchIndex) => (
<Skeleton
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
height={84}
width={220}
radius="md"
/>
))}
</Stack>
))}
</Flex>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
@@ -5,7 +5,7 @@ import {
} from "@/features/tournaments/queries"; } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import GroupStageView from "@/features/tournaments/components/group-stage-view"; import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container } from "@mantine/core"; import { Box, Card, Container, Group, Skeleton, SimpleGrid, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -28,8 +28,46 @@ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
}, },
}), }),
component: RouteComponent, component: RouteComponent,
pendingComponent: GroupsPending,
}); });
function GroupsPending() {
return (
<Container size="md" px={0}>
<Box p="md">
<Stack gap="md">
<Group gap={0} grow mb="md">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton
key={`groups-pending-tab-${index}`}
height={44}
radius="sm"
style={{ opacity: 0.85 - index * 0.1 }}
/>
))}
</Group>
<Card withBorder radius="md" p={0}>
<Group justify="space-between" p="sm">
<Skeleton height={16} width={120} radius="sm" />
<Skeleton height={22} width={22} radius="xl" />
</Group>
</Card>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton
key={`groups-pending-match-${index}`}
height={100}
radius="md"
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
/>
))}
</SimpleGrid>
</Stack>
</Box>
</Container>
);
}
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { data: tournament } = useTournament(id); const { data: tournament } = useTournament(id);
+7 -1
View File
@@ -23,7 +23,13 @@ export const Route = createFileRoute('/_authed/tournaments/')({
function RouteComponent() { function RouteComponent() {
return <Suspense fallback={<Stack gap="md"> return <Suspense fallback={<Stack gap="md">
{Array(10).fill(null).map((_, index) => ( {Array(10).fill(null).map((_, index) => (
<Skeleton height="120px" w="100%" /> <Skeleton
key={`tournament-card-skeleton-${index}`}
height="120px"
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.08, 0.35) }}
/>
))} ))}
</Stack>}> </Stack>}>
<TournamentCardList /> <TournamentCardList />
+4 -3
View File
@@ -98,20 +98,21 @@ const Avatar = ({
> >
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<ActionIcon <ActionIcon
variant="filled" variant="white"
color="dark" color="dark"
size="lg" size="lg"
radius="xl" radius="xl"
aria-label="Close image preview"
style={{ style={{
position: 'absolute', position: 'absolute',
top: -10, top: -10,
right: -10, right: -10,
zIndex: 1000, zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: 'rgba(255, 255, 255, 0.9)',
}} }}
onClick={() => setIsFullscreenOpen(false)} onClick={() => setIsFullscreenOpen(false)}
> >
<XIcon size={18} color="white" /> <XIcon size={18} />
</ActionIcon> </ActionIcon>
<Image <Image
+36
View File
@@ -0,0 +1,36 @@
.time {
transition: color 300ms ease-out;
}
.seconds {
display: inline-block;
}
.tick {
animation: digit-tick 200ms ease-out;
}
.finalTick {
animation: final-tick 250ms ease-out;
}
@keyframes digit-tick {
from {
opacity: 0.2;
transform: translateY(-0.12em);
}
}
@keyframes final-tick {
from {
opacity: 0.4;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.tick,
.finalTick {
animation: none;
}
}
+31 -13
View File
@@ -1,6 +1,7 @@
import useNow from '@/hooks/use-now'; import useNow from '@/hooks/use-now';
import { Text, Group } from '@mantine/core'; import { Text, Group } from '@mantine/core';
import { useMemo } from 'react'; import { useMemo } from 'react';
import classes from './countdown.module.css';
interface CountdownProps { interface CountdownProps {
date: Date; date: Date;
@@ -17,7 +18,7 @@ interface TimeLeft {
function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft { function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft {
const difference = targetDate.getTime() - currentTime.getTime(); const difference = targetDate.getTime() - currentTime.getTime();
if (difference <= 0) { if (difference <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0 }; return { days: 0, hours: 0, minutes: 0, seconds: 0 };
} }
@@ -30,28 +31,45 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
}; };
} }
const pad = (num: number) => num.toString().padStart(2, '0');
export function Countdown({ date, label, color }: CountdownProps) { export function Countdown({ date, label, color }: CountdownProps) {
const now = useNow(); const now = useNow();
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]); const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
const formatTime = () => { const totalSecondsLeft = Math.max(
const pad = (num: number) => num.toString().padStart(2, '0'); 0,
Math.floor((date.getTime() - now.getTime()) / 1000)
if (timeLeft.days > 0) { );
return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
} else {
return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`; const prefix =
} timeLeft.days > 0
}; ? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
return ( return (
<Group gap="xs"> <Group gap="xs">
{label && <Text size='sm' fw={500}>{label}:</Text>} {label && <Text size='sm' fw={500}>{label}:</Text>}
<Text size='sm' fw={600} c={color} ff="monospace"> <Text
{formatTime()} size='sm'
fw={600}
c={isFinalStretch ? 'red' : color}
ff="monospace"
className={classes.time}
>
{prefix}
<span
key={timeLeft.seconds}
className={`${classes.seconds} ${
isFinalStretch ? classes.finalTick : classes.tick
}`}
>
{pad(timeLeft.seconds)}
</span>
</Text> </Text>
</Group> </Group>
); );
} }
export default Countdown; export default Countdown;
+32
View File
@@ -0,0 +1,32 @@
import { Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { ReactNode } from "react";
interface EmptyStateProps {
icon: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
const EmptyState = ({ icon, title, description, action }: EmptyStateProps) => {
return (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
{icon}
</ThemeIcon>
<Stack align="center" gap={4}>
<Title order={3} c="dimmed" ta="center">
{title}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center" maw={280}>
{description}
</Text>
)}
</Stack>
{action}
</Stack>
);
};
export default EmptyState;
+16 -5
View File
@@ -16,6 +16,7 @@ interface GlitchAvatarProps
contain?: boolean; contain?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
px?: string | number; px?: string | number;
frame?: boolean;
} }
const GlitchAvatar = ({ const GlitchAvatar = ({
@@ -28,6 +29,7 @@ const GlitchAvatar = ({
contain = false, contain = false,
children, children,
px, px,
frame = false,
...props ...props
}: GlitchAvatarProps) => { }: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false); const [showGlitch, setShowGlitch] = useState(false);
@@ -37,13 +39,16 @@ const GlitchAvatar = ({
useEffect(() => { useEffect(() => {
if (!glitchSrc) return; if (!glitchSrc) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let timeoutId: ReturnType<typeof setTimeout>;
const scheduleNextGlitch = () => { const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000; const delay = Math.random() * 10000 + 5000;
return setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(true); setShowGlitch(true);
setIsPlaying(true); setIsPlaying(true);
setTimeout(() => { timeoutId = setTimeout(() => {
setShowGlitch(false); setShowGlitch(false);
setIsPlaying(false); setIsPlaying(false);
scheduleNextGlitch(); scheduleNextGlitch();
@@ -51,7 +56,7 @@ const GlitchAvatar = ({
}, delay); }, delay);
}; };
const timeoutId = scheduleNextGlitch(); scheduleNextGlitch();
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [glitchSrc]); }, [glitchSrc]);
@@ -94,12 +99,18 @@ const GlitchAvatar = ({
? `${radius + 8}px` ? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)", : "calc(var(--mantine-radius-md) + 8px)",
position: "relative", 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)",
}),
}} }}
> >
<Box <Box
style={{ style={{
opacity: showGlitch ? 0 : 1, opacity: showGlitch ? 0 : 1,
transition: "opacity 0.05s ease-in-out", transition: showGlitch
? "opacity 0.05s ease-in"
: "opacity 0.25s ease-out",
}} }}
> >
<Paper <Paper
@@ -141,7 +152,7 @@ const GlitchAvatar = ({
left: "8px", left: "8px",
opacity: showGlitch ? 1 : 0, opacity: showGlitch ? 1 : 0,
visibility: showGlitch ? "visible" : "hidden", visibility: showGlitch ? "visible" : "hidden",
transition: "opacity 0.05s ease-in-out", transition: showGlitch ? "opacity 0.05s ease-in" : "none",
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
+2
View File
@@ -1,5 +1,6 @@
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core"; import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import styles from "./list-row.module.css";
interface ListButtonProps { interface ListButtonProps {
label: string; label: string;
@@ -12,6 +13,7 @@ const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return ( return (
<> <>
<UnstyledButton <UnstyledButton
className={styles.listRow}
w="100%" w="100%"
p="md" p="md"
component={"button"} component={"button"}
+3 -1
View File
@@ -1,6 +1,7 @@
import { Divider, NavLink, Text } from "@mantine/core"; import { Divider, NavLink, Text } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react"; import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import styles from "./list-row.module.css";
interface ListLinkProps { interface ListLinkProps {
label: string; label: string;
@@ -15,6 +16,7 @@ const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
return ( return (
<> <>
<NavLink <NavLink
className={styles.listRow}
disabled={disabled} disabled={disabled}
w="100%" w="100%"
p="md" p="md"
+23
View File
@@ -0,0 +1,23 @@
.listRow {
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.listRow:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.listRow:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
@media (prefers-reduced-motion: no-preference) {
.listRow {
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.listRow:active:not(:disabled) {
transform: translateY(1px);
}
}
+16 -45
View File
@@ -1,17 +1,15 @@
import { import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
Box,
Container,
Title,
useComputedColorScheme,
} from "@mantine/core";
import { PropsWithChildren, useEffect, useRef } from "react"; import { PropsWithChildren, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul"; import { Drawer as VaulDrawer } from "vaul";
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
import styles from "./styles.module.css"; import styles from "./styles.module.css";
interface DrawerProps extends PropsWithChildren { interface DrawerProps extends PropsWithChildren {
title?: string; title?: string;
opened: boolean; opened: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
onClose?: () => void;
onExited?: () => void;
} }
const Drawer: React.FC<DrawerProps> = ({ const Drawer: React.FC<DrawerProps> = ({
@@ -19,55 +17,25 @@ const Drawer: React.FC<DrawerProps> = ({
children, children,
opened, opened,
onChange, onChange,
onExited,
}) => { }) => {
const colorScheme = useComputedColorScheme("light"); const colorScheme = useComputedColorScheme("light");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const openedRef = useRef(opened);
useEffect(() => { useEffect(() => {
openedRef.current = opened; if (!opened) return;
}, [opened]);
useEffect(() => { const appElement = document.querySelector<HTMLElement>(".app");
const appElement = document.querySelector(".app") as HTMLElement; const colors = CHROME_COLORS[colorScheme];
if (!appElement) return; appElement?.classList.add("drawer-scaling");
setThemeColorMeta(colors.dimmed);
let themeColorMeta = document.querySelector(
'meta[name="theme-color"]'
) as HTMLMetaElement;
if (!themeColorMeta) {
themeColorMeta = document.createElement("meta");
themeColorMeta.name = "theme-color";
document.head.appendChild(themeColorMeta);
}
const colors = {
light: {
normal: "rgb(255,255,255)",
overlay: "rgb(153,153,153)",
},
dark: {
normal: "rgb(36,36,36)",
overlay: "rgb(22,22,22)",
},
};
const currentColors = colors[colorScheme] || colors.light;
if (opened) {
appElement.classList.add("drawer-scaling");
themeColorMeta.content = currentColors.overlay;
} else {
appElement.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal;
}
return () => { return () => {
appElement.classList.remove("drawer-scaling"); appElement?.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal; setThemeColorMeta(colors.base);
}; };
}, [opened]); }, [opened, colorScheme]);
useEffect(() => { useEffect(() => {
if (!opened || !contentRef.current) return; if (!opened || !contentRef.current) return;
@@ -109,6 +77,9 @@ const Drawer: React.FC<DrawerProps> = ({
repositionInputs={false} repositionInputs={false}
open={opened} open={opened}
onOpenChange={onChange} onOpenChange={onChange}
onAnimationEnd={(open) => {
if (!open) onExited?.();
}}
> >
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} /> <VaulDrawer.Overlay className={styles.drawerOverlay} />
+18 -1
View File
@@ -5,13 +5,30 @@ interface ModalProps extends PropsWithChildren {
title?: string; title?: string;
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
onChange?: (next: boolean) => void;
onExited?: () => void;
} }
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => ( const Modal: React.FC<ModalProps> = ({
title,
children,
opened,
onClose,
onExited,
}) => (
<MantineModal <MantineModal
opened={opened} opened={opened}
onClose={onClose} onClose={onClose}
title={<Title order={3}>{title}</Title>} title={<Title order={3}>{title}</Title>}
radius={20}
transitionProps={{
transition: "pop",
duration: 200,
timingFunction: "ease-out",
onExited,
}}
overlayProps={{ backgroundOpacity: 0.4 }}
closeButtonProps={{ "aria-label": "Close" }}
> >
<Suspense <Suspense
fallback={ fallback={
+32 -2
View File
@@ -1,4 +1,11 @@
import { PropsWithChildren, Suspense, useCallback } from "react"; import {
PropsWithChildren,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer"; import Drawer from "./drawer";
import Modal from "./modal"; import Modal from "./modal";
@@ -10,13 +17,35 @@ interface SheetProps extends PropsWithChildren {
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
} }
const DRAWER_EXIT_MS = 500;
const MODAL_EXIT_MS = 200;
const EXIT_BUFFER_MS = 100;
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => { const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleClose = useCallback(() => onChange(false), [onChange]); const handleClose = useCallback(() => onChange(false), [onChange]);
const [mounted, setMounted] = useState(opened);
const openedRef = useRef(opened);
openedRef.current = opened;
const handleExited = useCallback(() => {
if (!openedRef.current) setMounted(false);
}, []);
useEffect(() => {
if (opened) {
setMounted(true);
return;
}
const exitDuration =
(isMobile ? DRAWER_EXIT_MS : MODAL_EXIT_MS) + EXIT_BUFFER_MS;
const timer = window.setTimeout(() => setMounted(false), exitDuration);
return () => window.clearTimeout(timer);
}, [opened, isMobile]);
const SheetComponent = isMobile ? Drawer : Modal; const SheetComponent = isMobile ? Drawer : Modal;
if (!opened) return null; if (!opened && !mounted) return null;
return ( return (
<SheetComponent <SheetComponent
@@ -24,6 +53,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
opened={opened} opened={opened}
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
onExited={handleExited}
> >
<Suspense fallback={ <Suspense fallback={
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}> <Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
@@ -150,7 +150,11 @@ const SlidePanel = ({
{panelConfig && ( {panelConfig && (
<> <>
<Group justify="space-between" p="md" align="center" w="100%"> <Group justify="space-between" p="md" align="center" w="100%">
<ActionIcon variant="transparent" onClick={closePanel}> <ActionIcon
variant="transparent"
onClick={closePanel}
aria-label="Back"
>
<ArrowLeftIcon size={24} /> <ArrowLeftIcon size={24} />
</ActionIcon> </ActionIcon>
<Text fw={500}>{panelConfig.title}</Text> <Text fw={500}>{panelConfig.title}</Text>
@@ -158,6 +162,7 @@ const SlidePanel = ({
variant="transparent" variant="transparent"
color="green" color="green"
onClick={handleConfirm} onClick={handleConfirm}
aria-label="Confirm"
> >
<CheckIcon size={24} /> <CheckIcon size={24} />
</ActionIcon> </ActionIcon>
+6
View File
@@ -20,3 +20,9 @@
outline: none; outline: none;
transition: height 0.2s ease-out, max-height 0.2s ease-out; transition: height 0.2s ease-out, max-height 0.2s ease-out;
} }
@media (prefers-reduced-motion: reduce) {
.drawerContent {
transition: none;
}
}
@@ -12,6 +12,8 @@ import {
Pagination, Pagination,
Code, Code,
Alert, Alert,
ThemeIcon,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -20,6 +22,7 @@ import {
CheckIcon, CheckIcon,
XIcon, XIcon,
ChecksIcon, ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types"; import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries"; import { useActivities } from "../queries";
@@ -220,9 +223,14 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
</Stack> </Stack>
{result.items.length === 0 && ( {result.items.length === 0 && (
<Text ta="center" c="dimmed" py="xl"> <Stack align="center" gap="md" py="xl">
No activities found <ThemeIcon size="xl" variant="light" radius="md">
</Text> <PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Activities Found
</Title>
</Stack>
)} )}
{result.totalPages > 1 && ( {result.totalPages > 1 && (
@@ -339,7 +347,7 @@ export const ActivitiesTable = () => {
<Text <Text
size="xs" size="xs"
fw={sortBy.includes("created") ? 600 : 400} fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "dark" : "dimmed"} c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
> >
Date Date
</Text> </Text>
@@ -355,7 +363,7 @@ export const ActivitiesTable = () => {
<Text <Text
size="xs" size="xs"
fw={sortBy.includes("duration") ? 600 : 400} fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "dark" : "dimmed"} c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
> >
Duration Duration
</Text> </Text>
@@ -7,7 +7,7 @@ const ManageTournaments = () => {
return ( return (
<List p="0"> <List p="0">
{tournaments.map((t) => ( {tournaments.map((t) => (
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} /> <ListLink key={t.id} label={t.name} to={`/admin/tournaments/${t.id}`} />
))} ))}
</List> </List>
); );
@@ -58,6 +58,28 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
}; };
const badgeTileCss = `
.flxn-badge-tile {
transition: transform 160ms cubic-bezier(0.32, 0.72, 0, 1);
}
@media (hover: hover) {
.flxn-badge-tile:hover {
transform: translateY(-2px);
}
}
.flxn-badge-tile:active {
transform: scale(0.97);
}
@media (prefers-reduced-motion: reduce) {
.flxn-badge-tile,
.flxn-badge-tile:hover,
.flxn-badge-tile:active {
transition: none;
transform: none;
}
}
`;
const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => { const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { data: badgeProgress } = usePlayerBadges(playerId); const { data: badgeProgress } = usePlayerBadges(playerId);
@@ -139,6 +161,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
return ( return (
<Box mb="lg"> <Box mb="lg">
<style href="flxn-badge-tile" precedence="medium">
{badgeTileCss}
</style>
<Box <Box
px="md" px="md"
style={{ style={{
@@ -168,9 +193,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
<Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal> <Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
<Popover.Target> <Popover.Target>
<Box <Box
className="flxn-badge-tile"
style={{ style={{
cursor: "pointer", cursor: "pointer",
transition: 'all 0.2s ease',
position: 'relative', position: 'relative',
}} }}
> >
@@ -4,6 +4,7 @@ import { BracketData } from "../types";
import { Bracket } from "./bracket"; import { Bracket } from "./bracket";
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";
interface BracketViewProps { interface BracketViewProps {
bracket: BracketData; bracket: BracketData;
@@ -25,7 +26,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
return <ScrollArea return <ScrollArea
h={`calc(${height})`} h={`calc(${height})`}
className="bracket-container" className={styles["bracket-container"]}
style={{ style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`, backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px", backgroundSize: "16px 16px",
@@ -134,6 +134,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id), queryKey: tournamentKeys.details(match.tournament.id),
}); });
editSheet.close();
}, },
}); });
@@ -149,9 +150,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
matchId: match.id, matchId: match.id,
}, },
}); });
editSheet.close();
}, },
[match.id, editSheet] [end, match.id]
); );
const speak = useCallback((text: string): Promise<void> => { const speak = useCallback((text: string): Promise<void> => {
@@ -311,6 +311,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
variant="subtle" variant="subtle"
color="gray" color="gray"
onClick={handleSpeakerClick} onClick={handleSpeakerClick}
aria-label="Announce matchup"
> >
<SpeakerHighIcon size={12} /> <SpeakerHighIcon size={12} />
</ActionIcon> </ActionIcon>
@@ -323,10 +324,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
<ActionIcon <ActionIcon
color="green" color="green"
onClick={handleStart} onClick={handleStart}
loading={start.isPending}
size="sm" size="sm"
h="100%" h="100%"
radius="sm" radius="sm"
ml={-4} ml={-4}
aria-label="Start match"
style={{ style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
@@ -343,10 +346,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
<ActionIcon <ActionIcon
color="blue" color="blue"
onClick={editSheet.open} onClick={editSheet.open}
loading={end.isPending}
size="sm" size="sm"
h="100%" h="100%"
radius="sm" radius="sm"
ml={-4} ml={-4}
aria-label="Edit match score"
style={{ style={{
borderTopLeftRadius: 0, borderTopLeftRadius: 0,
borderBottomLeftRadius: 0, borderBottomLeftRadius: 0,
@@ -363,6 +368,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
match={match} match={match}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
onCancel={editSheet.close} onCancel={editSheet.close}
loading={end.isPending}
/> />
</Sheet> </Sheet>
</Flex> </Flex>
+11 -3
View File
@@ -10,12 +10,14 @@ interface MatchFormProps {
ot_count: number; ot_count: number;
}) => void; }) => void;
onCancel: () => void; onCancel: () => void;
loading?: boolean;
} }
export const MatchForm: React.FC<MatchFormProps> = ({ export const MatchForm: React.FC<MatchFormProps> = ({
match, match,
onSubmit, onSubmit,
onCancel, onCancel,
loading = false,
}) => { }) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
@@ -35,7 +37,6 @@ export const MatchForm: React.FC<MatchFormProps> = ({
return "At least one team must have 10 cups"; return "At least one team must have 10 cups";
} }
// Both teams can't have 10 cups
if (homeCups === 10 && awayCups === 10) { if (homeCups === 10 && awayCups === 10) {
return "Both teams cannot have 10 cups"; return "Both teams cannot have 10 cups";
} }
@@ -145,8 +146,15 @@ export const MatchForm: React.FC<MatchFormProps> = ({
</Flex> </Flex>
<Stack mt="md"> <Stack mt="md">
<Button type="submit">Update Match</Button> <Button type="submit" loading={loading}>
<Button variant="subtle" color="red" onClick={onCancel}> Update Match
</Button>
<Button
variant="subtle"
color="red"
onClick={onCancel}
disabled={loading}
>
Cancel Cancel
</Button> </Button>
</Stack> </Stack>
@@ -0,0 +1,16 @@
.slotEnter {
animation: slot-enter 300ms ease-out;
}
@keyframes slot-enter {
from {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.slotEnter {
animation: none;
}
}
+77 -55
View File
@@ -1,8 +1,10 @@
import { Flex, Text } from "@mantine/core"; import { Flex, Text } from "@mantine/core";
import React from "react"; import React, { useEffect, useRef, useState } from "react";
import { CrownIcon } from "@phosphor-icons/react"; import { CrownIcon } from "@phosphor-icons/react";
import { SeedBadge } from "./seed-badge"; import { SeedBadge } from "./seed-badge";
import { TeamInfo } from "@/features/teams/types"; import { TeamInfo } from "@/features/teams/types";
import AnimatedScore from "@/features/matches/components/animated-score";
import classes from "./match-slot.module.css";
interface MatchSlotProps { interface MatchSlotProps {
from?: number; from?: number;
@@ -22,60 +24,80 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
cups, cups,
isWinner, isWinner,
groupLabel groupLabel
}) => ( }) => {
<Flex const teamId = team?.id;
align="stretch" const previousTeamIdRef = useRef<string | undefined>(teamId);
style={{ const [entranceKey, setEntranceKey] = useState(0);
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
borderRadius: 'var(--mantine-radius-sm)', useEffect(() => {
transition: 'background-color 200ms ease', const previousTeamId = previousTeamIdRef.current;
}} previousTeamIdRef.current = teamId;
> if (teamId && previousTeamId !== teamId) {
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined} setEntranceKey((key) => key + 1);
<Flex p="6px 10px" w='100%' align="center"> }
<Flex align="center" gap={4} flex={1}> }, [teamId]);
{team ? (
<> return (
<Text <Flex
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} align="stretch"
truncate style={{
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
> borderRadius: 'var(--mantine-radius-sm)',
{team.name} transition: 'background-color 200ms ease',
}}
>
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
<Flex p="6px 10px" w='100%' align="center">
<Flex
key={entranceKey}
className={entranceKey > 0 ? classes.slotEnter : undefined}
align="center"
gap={4}
flex={1}
>
{team ? (
<>
<Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
>
{team.name}
</Text>
{isWinner && (
<CrownIcon
size={14}
weight="fill"
style={{
color: 'gold',
marginLeft: '2px',
marginTop: '-1px',
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
flexShrink: 0
}}
/>
)}
</>
) : groupLabel ? (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{groupLabel}
</Text> </Text>
{isWinner && ( ) : from ? (
<CrownIcon <Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
size={14} {from_loser ? "Loser" : "Winner"} of Match {from}
weight="fill" </Text>
style={{ ) : (
color: 'gold', <Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
marginLeft: '2px', TBD
marginTop: '-1px', </Text>
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))', )}
flexShrink: 0 </Flex>
}} {
/> cups !== undefined ? (
)} <AnimatedScore ta='center' w={15} fw={800} ml={4} size="xs" value={cups} />
</> ) : undefined
) : groupLabel ? ( }
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{groupLabel}
</Text>
) : from ? (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
{from_loser ? "Loser" : "Winner"} of Match {from}
</Text>
) : (
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
TBD
</Text>
)}
</Flex> </Flex>
{
cups !== undefined ? (
<Text ta='center' w={15} fw="800" ml={4} size="xs">{cups}</Text>
) : undefined
}
</Flex> </Flex>
</Flex> );
); };
+5 -4
View File
@@ -1,4 +1,4 @@
import { Box } from "@mantine/core" import { UnstyledButton } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react" import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router" import { useRouter } from "@tanstack/react-router"
@@ -6,15 +6,16 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
const router = useRouter() const router = useRouter()
return ( return (
<Box <UnstyledButton
style={{ cursor: 'pointer', zIndex: 1000 }} aria-label='Go back'
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
onClick={() => router.history.back()} onClick={() => router.history.back()}
pos='absolute' pos='absolute'
left={left} left={left}
top={top} top={top}
> >
<ArrowLeftIcon weight='bold' size={20} /> <ArrowLeftIcon weight='bold' size={20} />
</Box> </UnstyledButton>
); );
} }
+15 -5
View File
@@ -1,19 +1,29 @@
import { Title, AppShell, Flex, Box, Paper } from "@mantine/core"; import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button"; import BackButton from "./back-button";
interface HeaderProps extends HeaderConfig {} interface HeaderProps extends HeaderConfig {
elevated?: boolean;
}
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => { const Header = ({ collapsed, title, withBackButton, elevated }: HeaderProps) => {
return ( return (
<AppShell.Header <AppShell.Header
id='app-header' id='app-header'
display={collapsed ? 'none' : 'flex'} display={collapsed ? 'none' : 'flex'}
style={{ alignItems: 'center', justifyContent: 'center' }} withBorder={false}
style={{
alignItems: 'center',
justifyContent: 'center',
borderBottom: `1px solid ${elevated ? 'var(--app-shell-border-color, var(--mantine-color-default-border))' : 'transparent'}`,
transition: 'border-color 200ms ease-out',
}}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' px='md' mt={8}> <Flex justify='center' px='md' mt={8}>
<Title order={1}>{title?.toLocaleUpperCase()}</Title> <Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
{title?.toLocaleUpperCase()}
</Title>
</Flex> </Flex>
</AppShell.Header> </AppShell.Header>
); );
+4 -3
View File
@@ -6,6 +6,7 @@ import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size'; import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config'; import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page'; import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => { const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig(); const { header, withPadding, fullWidth } = useRouterConfig();
@@ -35,15 +36,15 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
// top: viewport.top // top: viewport.top
}} }}
> >
<Header {...header} /> <Header {...header} elevated={scrollPosition.y > 2} />
<AppShell.Main <AppShell.Main
pos='relative' pos='relative'
h='100%' h='100%'
mah='100%' mah='100%'
pb={{ base: 65, sm: 0 }} pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Pullable> <Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
@@ -34,6 +34,8 @@ export const NavLink = ({
to={href} to={href}
className={styles.navLinkBox} className={styles.navLinkBox}
p={{ base: 0, sm: 8 }} p={{ base: 0, sm: 8 }}
aria-label={label}
aria-current={isActive ? "page" : undefined}
> >
<Flex <Flex
direction={{ base: "column", md: "row" }} direction={{ base: "column", md: "row" }}
@@ -2,5 +2,26 @@
text-decoration: none; text-decoration: none;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
color: unset; color: unset;
min-width: 2.75rem;
min-height: 2.75rem;
width: fit-content; width: fit-content;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) {
.navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
}
.navLinkBox:active {
transform: scale(0.92);
}
} }
+5 -7
View File
@@ -1,4 +1,4 @@
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core"; import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
import { NavLink } from "./nav-link"; import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
@@ -9,18 +9,16 @@ import { useIsPWA } from "@/hooks/use-is-pwa";
const Navbar = () => { const Navbar = () => {
const { user, roles } = useAuth() const { user, roles } = useAuth()
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { colorScheme } = useMantineColorScheme();
const isPWA = useIsPWA(); const isPWA = useIsPWA();
const links = useLinks(user?.id, roles); const links = useLinks(user?.id, roles);
const isDark = colorScheme === 'dark'; const bottomOffset = isPWA
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black'; ? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black'; : 'env(safe-area-inset-bottom, 0px)';
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
if (isMobile) return ( if (isMobile) return (
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={isPWA ? '1rem' : '0'} style={{ zIndex: 10 }}> <Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}> <Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
+18 -19
View File
@@ -1,17 +1,19 @@
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core"; import { Box, Flex, ScrollArea } from "@mantine/core";
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
import useAppShellHeight from "@/hooks/use-appshell-height"; import useAppShellHeight from "@/hooks/use-appshell-height";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react"; import { SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import useRouterConfig from "../hooks/use-router-config"; import useRouterConfig from "../hooks/use-router-config";
import { useLocation } from "@tanstack/react-router"; import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80; const THRESHOLD = 80;
/** interface PullableProps extends PropsWithChildren {
* Pullable is a component that allows the user to pull down to refresh the page scrollPosition: { x: number, y: number };
*/ onScrollPositionChange: (position: { x: number, y: number }) => void;
const Pullable: React.FC<PropsWithChildren> = ({ children }) => { }
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight(); const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false); const [scrolling, setScrolling] = useState(false);
@@ -31,16 +33,18 @@ const Pullable: React.FC<PropsWithChildren> = ({ children }) => {
const onTrigger = useCallback(async () => { const onTrigger = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
const minSpinnerDisplay = new Promise((resolve) => setTimeout(resolve, 400));
if (refresh.length > 0) { if (refresh.length > 0) {
// TODO: Remove this after testing - or does the delay help ux? await Promise.all(
await new Promise(resolve => setTimeout(resolve, 1000)); refresh.map((queryKey) => {
refresh.forEach(async (queryKey) => { const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey];
const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey]; return queryClient.refetchQueries({ queryKey: keyArray, exact: true });
await queryClient.refetchQueries({ queryKey: keyArray, exact: true }); })
}); );
} }
await minSpinnerDisplay;
setIsRefreshing(false); setIsRefreshing(false);
}, [refresh]); }, [refresh, queryClient]);
useEffect(() => { useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) { if (!isRefreshing && scrollY > THRESHOLD) {
@@ -87,7 +91,6 @@ const Pullable: React.FC<PropsWithChildren> = ({ children }) => {
return () => void ac.abort(); return () => void ac.abort();
}, []); }, []);
// Fix wheel scrolling over child elements
useEffect(() => { useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper'); const scrollWrapper = document.getElementById('scroll-wrapper');
if (!scrollWrapper) return; if (!scrollWrapper) return;
@@ -98,33 +101,29 @@ const Pullable: React.FC<PropsWithChildren> = ({ children }) => {
const handleWheel = (e: WheelEvent) => { const handleWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Check if the target is inside a nested scrollable container
let element = target; let element = target;
while (element && element !== viewport) { while (element && element !== viewport) {
const overflow = window.getComputedStyle(element).overflow; const overflow = window.getComputedStyle(element).overflow;
const overflowY = window.getComputedStyle(element).overflowY; const overflowY = window.getComputedStyle(element).overflowY;
const overflowX = window.getComputedStyle(element).overflowX; const overflowX = window.getComputedStyle(element).overflowX;
// If we found a scrollable ancestor (not the main viewport), don't interfere
if ( if (
(overflow === 'auto' || overflow === 'scroll' || (overflow === 'auto' || overflow === 'scroll' ||
overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'auto' || overflowY === 'scroll' ||
overflowX === 'auto' || overflowX === 'scroll') && overflowX === 'auto' || overflowX === 'scroll') &&
element !== viewport element !== viewport
) { ) {
// Check if this element can actually scroll in the wheel direction
const canScrollY = element.scrollHeight > element.clientHeight; const canScrollY = element.scrollHeight > element.clientHeight;
const canScrollX = element.scrollWidth > element.clientWidth; const canScrollX = element.scrollWidth > element.clientWidth;
if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) { if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) {
return; // Let the nested scroller handle it return;
} }
} }
element = element.parentElement as HTMLElement; element = element.parentElement as HTMLElement;
} }
// No nested scroller found, scroll the main viewport
viewport.scrollTop += e.deltaY; viewport.scrollTop += e.deltaY;
viewport.scrollLeft += e.deltaX; viewport.scrollLeft += e.deltaX;
}; };
@@ -0,0 +1,29 @@
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(root) {
animation: flxn-route-fade-out 120ms ease-out both;
}
::view-transition-new(root) {
animation: flxn-route-fade-in 200ms ease-out both;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
@keyframes flxn-route-fade-out {
to {
opacity: 0;
}
}
@keyframes flxn-route-fade-in {
from {
opacity: 0;
}
}
@@ -1,4 +1,4 @@
import { Box } from "@mantine/core" import { UnstyledButton } from "@mantine/core"
import { GearIcon } from "@phosphor-icons/react" import { GearIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
import { memo } from "react"; import { memo } from "react";
@@ -13,16 +13,17 @@ const SettingsButton = ({ to }: SettingButtonProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <UnstyledButton
style={{ cursor: 'pointer', zIndex: 1000 }} aria-label='Settings'
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
onClick={() => navigate({ to })} onClick={() => navigate({ to })}
pos='absolute' pos='absolute'
right={20} right={20}
top={6} top={6}
> >
<GearIcon weight='bold' size={20} /> <GearIcon weight='bold' size={20} />
</Box> </UnstyledButton>
); );
} }
export default memo(SettingsButton, (prev, next) => prev.to !== next.to); export default memo(SettingsButton, (prev, next) => prev.to === next.to);
+10 -3
View File
@@ -1,7 +1,7 @@
import GlitchAvatar from '@/components/glitch-avatar'; import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size'; import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { useCurrentTournament } from '@/features/tournaments/queries'; import { useCurrentTournament } from '@/features/tournaments/queries';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core'; import { AppShell, Flex, Paper, em, Title, Text, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks'; import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react'; import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
@@ -27,7 +27,8 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
style={{ transition: 'padding-top 0.1s ease' }} style={{ transition: 'padding-top 0.1s ease' }}
> >
<Paper <Paper
shadow='none' shadow='md'
withBorder
p='md' p='md'
w='100%' w='100%'
maw='375px' maw='375px'
@@ -37,6 +38,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
<GlitchAvatar <GlitchAvatar
name={tournament.name} name={tournament.name}
contain contain
frame
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -54,7 +56,12 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
> >
<TrophyIcon size={32} /> <TrophyIcon size={32} />
</GlitchAvatar> </GlitchAvatar>
<Title order={1} ta='center'>Welcome to FLXN</Title> <Stack align='center' gap={2}>
<Title order={1} ta='center'>Welcome to FLXN</Title>
<Text size='sm' c='dimmed' fs='italic' ta='center'>
Amicus meus madidus
</Text>
</Stack>
</Stack> </Stack>
{children} {children}
</Paper> </Paper>
+1 -1
View File
@@ -16,7 +16,7 @@ const LoginFlow = () => {
} }
return <Center> return <Center>
<Loader color="blue" size="xl" type="dots" /> <Loader size="xl" type="dots" />
</Center>; </Center>;
}; };
@@ -88,6 +88,7 @@ const PlayerPrompt = () => {
return <> return <>
<UnstyledButton <UnstyledButton
aria-label="Go back"
onClick={() => setStage(undefined)} onClick={() => setStage(undefined)}
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -0,0 +1,20 @@
.value {
display: inline-block;
}
.pulse {
animation: score-pulse 250ms ease-out;
}
@keyframes score-pulse {
from {
transform: scale(1.3);
color: var(--mantine-color-red-filled);
}
}
@media (prefers-reduced-motion: reduce) {
.pulse {
animation: none;
}
}
@@ -0,0 +1,34 @@
import { Text, TextProps } from "@mantine/core";
import { useEffect, useRef, useState } from "react";
import classes from "./animated-score.module.css";
interface AnimatedScoreProps extends TextProps {
value: number;
}
const AnimatedScore = ({ value, ...textProps }: AnimatedScoreProps) => {
const previousValueRef = useRef<number>(value);
const [pulseKey, setPulseKey] = useState(0);
useEffect(() => {
if (previousValueRef.current !== value) {
previousValueRef.current = value;
setPulseKey((key) => key + 1);
}
}, [value]);
return (
<Text {...textProps}>
<span
key={pulseKey}
className={
pulseKey > 0 ? `${classes.value} ${classes.pulse}` : classes.value
}
>
{value}
</span>
</Text>
);
};
export default AnimatedScore;
@@ -8,6 +8,7 @@ import { memo, Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet"; import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
import AnimatedScore from "./animated-score";
interface MatchCardProps { interface MatchCardProps {
match: Match; match: Match;
@@ -146,14 +147,13 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
</Text> </Text>
))} ))}
</Stack> </Stack>
<Text <AnimatedScore
size="xl" size="xl"
fw={700} fw={700}
c={"dimmed"} c={"dimmed"}
display={match.status === "ended" ? undefined : "none"} display={match.status === "ended" ? undefined : "none"}
> value={match.home_cups}
{match.home_cups} />
</Text>
</Group> </Group>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@@ -192,14 +192,13 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
</Text> </Text>
))} ))}
</Stack> </Stack>
<Text <AnimatedScore
size="xl" size="xl"
fw={700} fw={700}
c={"dimmed"} c={"dimmed"}
display={match.status === "ended" ? undefined : "none"} display={match.status === "ended" ? undefined : "none"}
> value={match.away_cups}
{match.away_cups} />
</Text>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>
@@ -330,7 +330,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("mmr")} onClick={() => handleSort("mmr")}
style={{ display: "flex", alignItems: "center", gap: 4 }} style={{ display: "flex", alignItems: "center", gap: 4 }}
> >
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}> <Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "var(--mantine-color-text)" : "dimmed"}>
MMR MMR
</Text> </Text>
{getSortIcon("mmr")} {getSortIcon("mmr")}
@@ -340,7 +340,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("wins")} onClick={() => handleSort("wins")}
style={{ display: "flex", alignItems: "center", gap: 4 }} style={{ display: "flex", alignItems: "center", gap: 4 }}
> >
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}> <Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "var(--mantine-color-text)" : "dimmed"}>
Wins Wins
</Text> </Text>
{getSortIcon("wins")} {getSortIcon("wins")}
@@ -350,14 +350,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("matches")} onClick={() => handleSort("matches")}
style={{ display: "flex", alignItems: "center", gap: 4 }} style={{ display: "flex", alignItems: "center", gap: 4 }}
> >
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}> <Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "var(--mantine-color-text)" : "dimmed"}>
Matches Matches
</Text> </Text>
{getSortIcon("matches")} {getSortIcon("matches")}
</UnstyledButton> </UnstyledButton>
<Popover position="bottom-end" withArrow shadow="md"> <Popover position="bottom-end" withArrow shadow="md">
<Popover.Target> <Popover.Target>
<ActionIcon variant="subtle" size="sm"> <ActionIcon variant="subtle" size="sm" aria-label="Stat abbreviations and MMR info">
<InfoIcon size={14} /> <InfoIcon size={14} />
</ActionIcon> </ActionIcon>
</Popover.Target> </Popover.Target>
@@ -30,7 +30,7 @@ const StatsWithFilter = ({ id }: { id: string }) => {
</Group> </Group>
</Group> </Group>
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}> <Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
<Suspense key={deferredViewType} fallback={<StatsSkeleton />}> <Suspense fallback={<StatsSkeleton />}>
<StatsContent id={id} viewType={deferredViewType} /> <StatsContent id={id} viewType={deferredViewType} />
</Suspense> </Suspense>
</Box> </Box>
@@ -39,8 +39,8 @@ const StatsWithFilter = ({ id }: { id: string }) => {
}; };
const StatsContent = ({ id, viewType }: { id: string; viewType: 'all' | 'mainline' | 'regional' }) => { const StatsContent = ({ id, viewType }: { id: string; viewType: 'all' | 'mainline' | 'regional' }) => {
const { data: stats, isLoading: statsLoading } = usePlayerStats(id, viewType); const { data: stats } = usePlayerStats(id, viewType);
return <StatsOverview statsData={stats} isLoading={statsLoading} />; return <StatsOverview statsData={stats} />;
}; };
const Profile = ({ id }: ProfileProps) => { const Profile = ({ id }: ProfileProps) => {
@@ -1,31 +1,75 @@
import { Box, Flex, Loader } from "@mantine/core"; import { Box, Divider, Group, Paper, Skeleton, Stack } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import StatsOverview, { StatsSkeleton } from "@/components/stats-overview"; import { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list"; import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import HeaderSkeleton from "./header-skeleton"; import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => ( const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Flex h="30vh" w="100%" align="center" justify="center"> <Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
<Loader /> <Stack gap="sm">
</Flex> <Skeleton height={12} width="55%" radius="sm" />
) {[0, 1].map((row) => (
<Group key={row} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={14} width="40%" radius="sm" />
</Group>
<Skeleton height={12} width={64} radius="sm" />
</Group>
))}
</Stack>
</Paper>
);
const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
<Stack p="md" gap="sm">
{Array.from({ length: count }).map((_, index) => (
<MatchCardSkeleton
key={`match-skeleton-${index}`}
opacity={Math.max(1 - index * 0.15, 0.4)}
/>
))}
</Stack>
);
const OverviewSkeleton = () => (
<>
<Stack px="md">
<Group justify="space-between" align="center">
<Skeleton height={16} width={70} radius="sm" />
<Skeleton height={14} width={100} radius="sm" />
</Group>
<BadgeShowcaseSkeleton />
</Stack>
<Divider my="md" />
<Stack>
<Group gap="xs" px="md" justify="space-between" align="center">
<Skeleton height={16} width={80} radius="sm" />
<Group gap="xs">
<Skeleton height={22} width={40} radius="xl" />
<Skeleton height={22} width={64} radius="xl" />
<Skeleton height={22} width={62} radius="xl" />
</Group>
</Group>
<StatsSkeleton />
</Stack>
</>
);
const ProfileSkeleton = () => { const ProfileSkeleton = () => {
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <StatsSkeleton />, content: <OverviewSkeleton />,
}, },
{ {
label: "Matches", label: "Matches",
content: <SkeletonLoader />, content: <MatchListSkeleton />,
}, },
{ {
label: "Teams", label: "Teams",
content: <SkeletonLoader />, content: <TeamList teams={[]} loading />,
}, },
]; ];
@@ -0,0 +1,20 @@
.count {
display: inline-block;
}
.countTick {
animation: count-tick 180ms ease-out;
}
@keyframes count-tick {
from {
opacity: 0;
transform: translateY(-0.35em);
}
}
@media (prefers-reduced-motion: reduce) {
.countTick {
animation: none;
}
}
+121 -81
View File
@@ -2,31 +2,64 @@ import {
Group, Group,
Button, Button,
Text, Text,
TextProps,
Stack, Stack,
ScrollArea, ScrollArea,
Paper, Paper,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import PlayerList from "@/features/players/components/player-list"; import PlayerList from "@/features/players/components/player-list";
import EmojiPicker from "./emoji-picker"; import EmojiPicker from "./emoji-picker";
import { useMatchReactions, useToggleMatchReaction } from "../queries"; import { useMatchReactions, useToggleMatchReaction } from "../queries";
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { Reaction } from "@/features/matches/server"; import { Reaction } from "@/features/matches/server";
import classes from "./emoji-bar.module.css";
interface EmojiBarProps { interface EmojiBarProps {
matchId: string; matchId: string;
onReactionPress?: (emoji: string) => void; onReactionPress?: (emoji: string) => void;
} }
const EASE: [number, number, number, number] = [0.32, 0.72, 0, 1];
interface TickingCountProps extends TextProps {
value: number;
}
const TickingCount = ({ value, ...textProps }: TickingCountProps) => {
const mountedRef = useRef(false);
useEffect(() => {
mountedRef.current = true;
}, []);
return (
<Text component="span" {...textProps}>
<span
key={value}
className={
mountedRef.current
? `${classes.count} ${classes.countTick}`
: classes.count
}
>
{value}
</span>
</Text>
);
};
const EmojiBar = ({ const EmojiBar = ({
matchId, matchId,
onReactionPress, onReactionPress,
}: EmojiBarProps) => { }: EmojiBarProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { data: reactions } = useMatchReactions(matchId); const { data: reactions } = useMatchReactions(matchId);
const toggleReaction = useToggleMatchReaction(matchId); const toggleReaction = useToggleMatchReaction(matchId, user);
const reduceMotion = useReducedMotion();
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null); const [selectedEmoji, setSelectedEmoji] = useState<string | null>(null);
@@ -71,87 +104,96 @@ const EmojiBar = ({
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0); const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r)); const userHasReactedToGrouped = groupedReactions.some(r => hasReacted(r));
const chipMotionProps = {
layout: !reduceMotion,
initial: reduceMotion ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.8 },
transition: { duration: reduceMotion ? 0 : 0.18, ease: EASE },
};
return ( return (
<> <>
<Group gap="xs" wrap="wrap" justify="space-between"> <Group gap="xs" wrap="wrap" justify="space-between">
<Group gap="xs" wrap="wrap"> <Group gap="xs" wrap="wrap">
{visibleReactions.map((reaction) => ( <AnimatePresence initial={false} mode="popLayout">
<Button {visibleReactions.map((reaction) => (
key={reaction.emoji} <motion.div key={reaction.emoji} {...chipMotionProps}>
variant={"light"} <Button
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined} variant={"light"}
size="compact-xs" bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
radius="xl" size="compact-xs"
onMouseDown={() => handleLongPressStart(reaction.emoji)} radius="xl"
onMouseUp={handleLongPressEnd} onMouseDown={() => handleLongPressStart(reaction.emoji)}
onMouseLeave={handleLongPressEnd} onMouseUp={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(reaction.emoji)} onMouseLeave={handleLongPressEnd}
onTouchEnd={handleLongPressEnd} onTouchStart={() => handleLongPressStart(reaction.emoji)}
onClick={() => handleReactionClick(reaction.emoji)} onTouchEnd={handleLongPressEnd}
style={{ onClick={() => handleReactionClick(reaction.emoji)}
userSelect: "none", style={{
WebkitUserSelect: "none", userSelect: "none",
MozUserSelect: "none", WebkitUserSelect: "none",
msUserSelect: "none", MozUserSelect: "none",
}} msUserSelect: "none",
> }}
<Group gap={2} align="center"> >
<Text size="xs" style={{ lineHeight: 1 }}> <Group gap={2} align="center">
{reaction.emoji} <Text size="xs" style={{ lineHeight: 1 }}>
</Text> {reaction.emoji}
<Text size="xs" fw={600}>
{reaction.count}
</Text>
</Group>
</Button>
))}
{hasGrouped && (
<Button
variant={"light"}
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onTouchEnd={handleLongPressEnd}
onClick={() => {
setSelectedEmoji(groupedReactions[0]?.emoji || "");
open();
}}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
position: "relative",
}}
>
<Group gap={2} align="center">
<div style={{
display: "flex",
gap: "1px",
alignItems: "center",
fontSize: "10px",
lineHeight: 1
}}>
{groupedReactions.slice(0, 2).map((reaction) => (
<span key={reaction.emoji}>{reaction.emoji}</span>
))}
{groupedReactions.length > 2 && (
<Text size="8px" fw={600} c="dimmed">
+{groupedReactions.length - 2}
</Text> </Text>
)} <TickingCount size="xs" fw={600} value={reaction.count} />
</div> </Group>
<Text size="xs" fw={600}> </Button>
{groupedCount} </motion.div>
</Text> ))}
</Group>
</Button> {hasGrouped && (
)} <motion.div key="grouped-reactions" {...chipMotionProps}>
<Button
variant={"light"}
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
onMouseDown={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onMouseUp={handleLongPressEnd}
onMouseLeave={handleLongPressEnd}
onTouchStart={() => handleLongPressStart(groupedReactions[0]?.emoji || "")}
onTouchEnd={handleLongPressEnd}
onClick={() => {
setSelectedEmoji(groupedReactions[0]?.emoji || "");
open();
}}
style={{
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
position: "relative",
}}
>
<Group gap={2} align="center">
<div style={{
display: "flex",
gap: "1px",
alignItems: "center",
fontSize: "10px",
lineHeight: 1
}}>
{groupedReactions.slice(0, 2).map((reaction) => (
<span key={reaction.emoji}>{reaction.emoji}</span>
))}
{groupedReactions.length > 2 && (
<Text size="8px" fw={600} c="dimmed">
+{groupedReactions.length - 2}
</Text>
)}
</div>
<TickingCount size="xs" fw={600} value={groupedCount} />
</Group>
</Button>
</motion.div>
)}
</AnimatePresence>
</Group> </Group>
<EmojiPicker <EmojiPicker
onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))} onSelect={onReactionPress || ((emoji) => toggleReaction.mutate({ data: { matchId, emoji } }))}
@@ -175,9 +217,7 @@ const EmojiBar = ({
> >
<Group gap={4} align="center"> <Group gap={4} align="center">
<Text size="sm">{reaction.emoji}</Text> <Text size="sm">{reaction.emoji}</Text>
<Text size="xs" fw={600}> <TickingCount size="xs" fw={600} value={reaction.count} />
{reaction.count}
</Text>
</Group> </Group>
</Button> </Button>
))} ))}
+61 -11
View File
@@ -1,6 +1,13 @@
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks"; import {
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server"; useOptimisticMutation,
import { useQueryClient } from "@tanstack/react-query"; useServerSuspenseQuery,
} from "@/lib/tanstack-query/hooks";
import {
getMatchReactions,
toggleMatchReaction,
Reaction,
} from "@/features/matches/server";
import { PlayerInfo } from "@/features/players/types";
export const reactionKeys = { export const reactionKeys = {
match: (matchId: string) => ['reactions', 'match', matchId] as const, match: (matchId: string) => ['reactions', 'match', matchId] as const,
@@ -16,15 +23,58 @@ export const reactionQueries = {
export const useMatchReactions = (matchId: string) => export const useMatchReactions = (matchId: string) =>
useServerSuspenseQuery(reactionQueries.match(matchId)); useServerSuspenseQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => { const toggleReactionInList = (
const queryClient = useQueryClient(); reactions: Reaction[],
emoji: string,
user: PlayerInfo
): Reaction[] => {
const existing = reactions.find((reaction) => reaction.emoji === emoji);
const hasReacted =
existing?.players.some((player) => player.id === user.id) ?? false;
return useServerMutation({ if (hasReacted) {
return reactions
.map((reaction) =>
reaction.emoji === emoji
? {
...reaction,
count: reaction.count - 1,
players: reaction.players.filter(
(player) => player.id !== user.id
),
}
: reaction
)
.filter((reaction) => reaction.count > 0);
}
const optimisticPlayer: PlayerInfo = {
id: user.id,
first_name: user.first_name,
last_name: user.last_name,
};
if (existing) {
return reactions.map((reaction) =>
reaction.emoji === emoji
? {
...reaction,
count: reaction.count + 1,
players: [...reaction.players, optimisticPlayer],
}
: reaction
);
}
return [...reactions, { emoji, count: 1, players: [optimisticPlayer] }];
};
export const useToggleMatchReaction = (matchId: string, user?: PlayerInfo) =>
useOptimisticMutation({
mutationFn: toggleMatchReaction, mutationFn: toggleMatchReaction,
onSuccess: () => { queryKey: reactionKeys.match(matchId),
queryClient.invalidateQueries({ optimisticUpdate: (oldData: Reaction[] | undefined, variables) => {
queryKey: reactionKeys.match(matchId) if (!oldData || !user) return oldData;
}); return toggleReactionInList(oldData, variables.data.emoji, user);
}, },
}); });
};
@@ -2,10 +2,11 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list"; import TournamentList from "@/features/tournaments/components/tournament-list";
import StatsOverview from "@/components/stats-overview"; import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import { useTeam, useTeamMatches, useTeamStats } from "../../queries"; import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list"; import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list"; import PlayerList from "@/features/players/components/player-list";
import { MatchListSkeleton } from "./skeleton";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -13,7 +14,7 @@ interface ProfileProps {
const TeamProfile = ({ id }: ProfileProps) => { const TeamProfile = ({ id }: ProfileProps) => {
const { data: team } = useTeam(id); const { data: team } = useTeam(id);
const { data: matches } = useTeamMatches(id); const { data: matches, isLoading: matchesLoading } = useTeamMatches(id);
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id); const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
if (!team) return <Text p="md">Team not found</Text>; if (!team) return <Text p="md">Team not found</Text>;
@@ -28,13 +29,21 @@ const TeamProfile = ({ id }: ProfileProps) => {
<Divider my="md" /> <Divider my="md" />
<Stack> <Stack>
<Text px="md" size="md" fw={700}>Statistics</Text> <Text px="md" size="md" fw={700}>Statistics</Text>
<StatsOverview statsData={statsError ? null : stats || null} isLoading={statsLoading} /> {statsLoading ? (
<StatsSkeleton />
) : (
<StatsOverview statsData={statsError ? null : stats || null} />
)}
</Stack> </Stack>
</>, </>,
}, },
{ {
label: "Matches", label: "Matches",
content: <MatchList matches={matches || []} />, content: matchesLoading ? (
<MatchListSkeleton />
) : (
<MatchList matches={matches || []} />
),
}, },
{ {
label: "Tournaments", label: "Tournaments",
@@ -1,26 +1,80 @@
import { Box, Flex, Loader } from "@mantine/core"; import { Box, Divider, Group, List, ListItem, Paper, Skeleton, Stack } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import { StatsSkeleton } from "@/components/stats-overview";
import HeaderSkeleton from "./header-skeleton"; import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => ( const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Flex h="30vh" w="100%" align="center" justify="center"> <Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
<Loader /> <Stack gap="sm">
</Flex> <Skeleton height={12} width="55%" radius="sm" />
) {[0, 1].map((row) => (
<Group key={row} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={14} width="40%" radius="sm" />
</Group>
<Skeleton height={12} width={64} radius="sm" />
</Group>
))}
</Stack>
</Paper>
);
export const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
<Stack p="md" gap="sm">
{Array.from({ length: count }).map((_, index) => (
<MatchCardSkeleton
key={`match-skeleton-${index}`}
opacity={Math.max(1 - index * 0.15, 0.4)}
/>
))}
</Stack>
);
const PlayerListSkeleton = ({ count = 2 }: { count?: number }) => (
<List>
{Array.from({ length: count }).map((_, index) => (
<ListItem
py="xs"
key={`player-skeleton-${index}`}
icon={<Skeleton circle height={40} width={40} />}
>
<Skeleton height={16} width={180} radius="sm" />
</ListItem>
))}
</List>
);
export const OverviewSkeleton = () => (
<>
<Stack px="md">
<Skeleton height={16} width={70} radius="sm" />
<PlayerListSkeleton />
</Stack>
<Divider my="md" />
<Stack>
<Box px="md">
<Skeleton height={16} width={80} radius="sm" />
</Box>
<StatsSkeleton />
</Stack>
</>
);
const ProfileSkeleton = () => { const ProfileSkeleton = () => {
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <SkeletonLoader />, content: <OverviewSkeleton />,
}, },
{ {
label: "Matches", label: "Matches",
content: <SkeletonLoader />, content: <MatchListSkeleton />,
}, },
{ {
label: "Tournaments", label: "Tournaments",
content: <SkeletonLoader />, content: <TournamentList tournaments={[]} loading />,
}, },
]; ];
@@ -10,6 +10,7 @@ import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { MatchForm } from "@/features/bracket/components/match-form"; import { MatchForm } from "@/features/bracket/components/match-form";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
interface GroupMatchCardProps { interface GroupMatchCardProps {
match: Match; match: Match;
@@ -104,14 +105,13 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
</Text> </Text>
</Group> </Group>
{isEnded && match.home_cups !== undefined && ( {isEnded && match.home_cups !== undefined && (
<Text <AnimatedScore
value={match.home_cups}
size="xl" size="xl"
fw={700} fw={700}
c={homeWon ? "green" : "dimmed"} c={homeWon ? "green" : "dimmed"}
style={{ minWidth: 32, textAlign: 'center' }} style={{ minWidth: 32, textAlign: 'center' }}
> />
{match.home_cups}
</Text>
)} )}
</Group> </Group>
@@ -135,14 +135,13 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
</Text> </Text>
</Group> </Group>
{isEnded && match.away_cups !== undefined && ( {isEnded && match.away_cups !== undefined && (
<Text <AnimatedScore
value={match.away_cups}
size="xl" size="xl"
fw={700} fw={700}
c={awayWon ? "green" : "dimmed"} c={awayWon ? "green" : "dimmed"}
style={{ minWidth: 32, textAlign: 'center' }} style={{ minWidth: 32, textAlign: 'center' }}
> />
{match.away_cups}
</Text>
)} )}
</Group> </Group>
</Stack> </Stack>
@@ -152,6 +151,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
{showStartButton && ( {showStartButton && (
<ActionIcon <ActionIcon
color="green" color="green"
aria-label="Start match"
onClick={handleStartMatch} onClick={handleStartMatch}
loading={start.isPending} loading={start.isPending}
size="md" size="md"
@@ -170,6 +170,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
{showEditButton && ( {showEditButton && (
<ActionIcon <ActionIcon
color="blue" color="blue"
aria-label="Edit match score"
onClick={editSheet.open} onClick={editSheet.open}
size="md" size="md"
h="100%" h="100%"
@@ -191,6 +192,7 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
match={match} match={match}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
onCancel={editSheet.close} onCancel={editSheet.close}
loading={end.isPending}
/> />
</Sheet> </Sheet>
)} )}
@@ -367,7 +367,11 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<Text fw={600} size="sm"> <Text fw={600} size="sm">
Standings ({standings.length}) Standings ({standings.length})
</Text> </Text>
<ActionIcon variant="subtle" size="sm"> <ActionIcon
variant="subtle"
size="sm"
aria-label={expandedTeams[group.id] ? "Collapse standings" : "Expand standings"}
>
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />} {expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
</ActionIcon> </ActionIcon>
</MantineGroup> </MantineGroup>
+125 -81
View File
@@ -1,101 +1,145 @@
import { Stack, Group, Text, ThemeIcon, Box, Center } from "@mantine/core"; import { Stack, Group, Text, ThemeIcon } from "@mantine/core";
import { CrownIcon, MedalIcon } from "@phosphor-icons/react"; import { CrownIcon, MedalIcon } from "@phosphor-icons/react";
import { TeamInfo } from "@/features/teams/types";
import { Tournament } from "../types"; import { Tournament } from "../types";
interface PodiumProps { interface PodiumProps {
tournament: Tournament; tournament: Tournament;
} }
type PodiumTier = "first" | "second" | "third";
const tierStyles: Record<PodiumTier, React.CSSProperties> = {
first: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-yellow-4) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-yellow-8) 16%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(color-mix(in srgb, var(--mantine-color-yellow-6) 45%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-yellow-6) 35%, var(--mantine-color-dark-4)))",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-md)",
},
second: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-gray-5) 10%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
third: {
background:
"light-dark(color-mix(in srgb, var(--mantine-color-orange-5) 8%, var(--mantine-color-white)), color-mix(in srgb, var(--mantine-color-orange-9) 14%, var(--mantine-color-dark-6)))",
border:
"1px solid light-dark(color-mix(in srgb, var(--mantine-color-orange-6) 35%, var(--mantine-color-gray-3)), color-mix(in srgb, var(--mantine-color-orange-7) 30%, var(--mantine-color-dark-4)))",
borderRadius: "var(--mantine-radius-md)",
boxShadow: "var(--mantine-shadow-xs)",
},
};
const tierConfig: Record<
PodiumTier,
{
color: string;
label: string;
labelColor: string;
icon: React.ReactNode;
iconSize: "xl" | "lg";
padding: "md" | "xs";
nameSize: "md" | "sm";
}
> = {
first: {
color: "yellow",
label: "Champions",
labelColor:
"light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4))",
icon: <CrownIcon size={24} />,
iconSize: "xl",
padding: "md",
nameSize: "md",
},
second: {
color: "gray",
label: "2nd place",
labelColor:
"light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
icon: <MedalIcon size={20} />,
iconSize: "lg",
padding: "xs",
nameSize: "sm",
},
third: {
color: "orange",
label: "3rd place",
labelColor:
"light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4))",
icon: <MedalIcon size={18} />,
iconSize: "lg",
padding: "xs",
nameSize: "sm",
},
};
const PodiumRow = ({ team, tier }: { team: TeamInfo; tier: PodiumTier }) => {
const config = tierConfig[tier];
return (
<Group gap="md" p={config.padding} style={tierStyles[tier]}>
<ThemeIcon
size={config.iconSize}
color={config.color}
variant="light"
radius="xl"
>
{config.icon}
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size={config.nameSize} fw={tier === "first" ? 700 : 600}>
{team.name}
</Text>
<Group gap="xs">
{team.players?.map((player) => (
<Text
key={player.id}
size={tier === "first" ? "sm" : "xs"}
c="dimmed"
>
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{
letterSpacing: "0.08em",
alignSelf: "flex-start",
color: config.labelColor,
}}
mt={4}
mr={4}
>
{config.label}
</Text>
</Group>
);
};
export const Podium = ({ tournament }: PodiumProps) => { export const Podium = ({ tournament }: PodiumProps) => {
if (!tournament.first_place) return; if (!tournament.first_place) return;
return ( return (
<Stack gap="xs" px="md"> <Stack gap="xs" px="md">
{tournament.first_place && ( {tournament.first_place && (
<Group <PodiumRow team={tournament.first_place} tier="first" />
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 && ( {tournament.second_place && (
<Group <PodiumRow team={tournament.second_place} tier="second" />
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 && ( {tournament.third_place && (
<Group <PodiumRow team={tournament.third_place} tier="third" />
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> </Stack>
); );
@@ -1,26 +1,89 @@
import { Box, Flex, Loader } from "@mantine/core"; import { Box, Divider, Group, Paper, Skeleton, Stack } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import TeamList from "@/features/teams/components/team-list";
import HeaderSkeleton from "./header-skeleton"; import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => ( const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Flex h="30vh" w="100%" align="center" justify="center"> <Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
<Loader /> <Stack gap="sm">
</Flex> <Skeleton height={12} width="55%" radius="sm" />
) {[0, 1].map((row) => (
<Group key={row} justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={14} width="40%" radius="sm" />
</Group>
<Skeleton height={12} width={64} radius="sm" />
</Group>
))}
</Stack>
</Paper>
);
const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
<Stack p="md" gap="sm">
{Array.from({ length: count }).map((_, index) => (
<MatchCardSkeleton
key={`match-skeleton-${index}`}
opacity={Math.max(1 - index * 0.15, 0.4)}
/>
))}
</Stack>
);
const ResultRowSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Box p="md" style={{ opacity }}>
<Group gap="sm" align="center" wrap="nowrap">
<Skeleton height={40} width={40} radius="sm" />
<Stack gap={8} style={{ flex: 1 }}>
<Skeleton height={14} width="45%" radius="sm" />
<Group gap="md" wrap="nowrap">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={`result-stat-${index}`} height={20} width={22} radius="sm" />
))}
</Group>
</Stack>
</Group>
</Box>
);
const OverviewSkeleton = () => (
<Stack gap="md">
<Box>
<Group p="md" wrap="nowrap" w="100%">
<Skeleton height={20} width={20} radius="sm" />
<Skeleton height={16} width={110} radius="sm" />
<Skeleton ml="auto" height={20} width={20} radius="sm" />
</Group>
<Divider />
</Box>
<Stack gap={0}>
<Box px="md" pb="xs">
<Skeleton height={18} width={70} radius="sm" />
</Box>
{Array.from({ length: 4 }).map((_, index) => (
<Box key={`result-row-${index}`}>
<ResultRowSkeleton opacity={Math.max(1 - index * 0.15, 0.4)} />
{index < 3 && <Divider />}
</Box>
))}
</Stack>
</Stack>
);
const ProfileSkeleton = () => { const ProfileSkeleton = () => {
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <SkeletonLoader />, content: <OverviewSkeleton />,
}, },
{ {
label: "Matches", label: "Matches",
content: <SkeletonLoader />, content: <MatchListSkeleton />,
}, },
{ {
label: "Teams", label: "Teams",
content: <SkeletonLoader />, content: <TeamList teams={[]} loading />,
}, },
]; ];
@@ -2,10 +2,11 @@ import { useAuth } from "@/contexts/auth-context";
import { useTournaments } from "../queries"; import { useTournaments } from "../queries";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import { Button, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { PlusIcon } from "@phosphor-icons/react"; import { PlusIcon, TrophyIcon } from "@phosphor-icons/react";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form"; import TournamentForm from "./tournament-form";
import { TournamentCard } from "./tournament-card"; import { TournamentCard } from "./tournament-card";
import EmptyState from "@/components/empty-state";
const TournamentCardList = () => { const TournamentCardList = () => {
const { data: tournaments } = useTournaments(); const { data: tournaments } = useTournaments();
@@ -27,9 +28,17 @@ const TournamentCardList = () => {
</Sheet> </Sheet>
</> </>
) : null} ) : null}
{tournaments?.map((tournament: any) => ( {tournaments && tournaments.length > 0 ? (
<TournamentCard key={tournament.id} tournament={tournament} /> tournaments.map((tournament: any) => (
))} <TournamentCard key={tournament.id} tournament={tournament} />
))
) : (
<EmptyState
icon={<TrophyIcon size={32} />}
title="No Tournaments Yet"
description="The cups are racked and waiting. Check back soon."
/>
)}
</Stack> </Stack>
); );
}; };
@@ -20,6 +20,27 @@ interface TournamentCardProps {
tournament: TournamentInfo; tournament: TournamentInfo;
} }
const tournamentCardCss = `
.flxn-tournament-card {
transition: border-color 160ms ease-out, box-shadow 160ms ease-out;
}
@media (hover: hover) {
.flxn-tournament-card.flxn-tournament-card:hover {
border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 40%, var(--mantine-color-default-border));
box-shadow: var(--mantine-shadow-sm);
}
}
.flxn-tournament-card.flxn-tournament-card:active {
border-color: color-mix(in srgb, var(--mantine-primary-color-filled) 55%, var(--mantine-color-default-border));
box-shadow: none;
}
@media (prefers-reduced-motion: reduce) {
.flxn-tournament-card {
transition: none;
}
}
`;
export const TournamentCard = ({ tournament }: TournamentCardProps) => { export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,15 +50,15 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
style={{ borderRadius: "var(--mantine-radius-md)" }} style={{ borderRadius: "var(--mantine-radius-md)" }}
> >
<style href="flxn-tournament-card" precedence="medium">
{tournamentCardCss}
</style>
<Card <Card
withBorder withBorder
radius="md" radius="md"
p="lg" p="lg"
w="100%" w="100%"
style={{ className="flxn-tournament-card"
transition: "all 0.15s ease",
border: "1px solid var(--mantine-color-default-border)",
}}
> >
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="md" align="center"> <Group gap="md" align="center">
@@ -2,7 +2,7 @@ import { List, ListItem, Divider, Skeleton, Text, Group, Box, ThemeIcon, Stack }
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { TournamentInfo } from "../types"; import { TournamentInfo } from "../types";
import { useCallback } from "react"; import { Fragment, useCallback } from "react";
import React from "react"; import React from "react";
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react"; import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
@@ -99,9 +99,8 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
return ( return (
<List p="0"> <List p="0">
{tournaments.map((tournament) => ( {tournaments.map((tournament) => (
<> <Fragment key={tournament.id}>
<ListItem <ListItem
key={tournament.id}
p="xs" p="xs"
icon={ icon={
<Avatar <Avatar
@@ -129,7 +128,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
<TournamentListItem tournament={tournament} /> <TournamentListItem tournament={tournament} />
</ListItem> </ListItem>
<Divider /> <Divider />
</> </Fragment>
))} ))}
</List> </List>
); );
@@ -98,7 +98,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
{freeAgents {freeAgents
.filter(agent => agent.player) .filter(agent => agent.player)
.map((agent) => ( .map((agent) => (
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-gray-0)' }}> <Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-default-hover)' }}>
<Text size="sm" fw={500} truncate> <Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name} {agent.player?.first_name} {agent.player?.last_name}
</Text> </Text>
@@ -107,6 +107,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
size="sm" size="sm"
aria-label="Copy phone number"
onClick={() => copyToClipboard(agent.phone!)} onClick={() => copyToClipboard(agent.phone!)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
@@ -55,7 +55,16 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
<Stack px="xs"> <Stack px="xs">
{tournament.desc && <Text px="md" ta="center" size="sm" style={{ whiteSpace: 'pre-wrap' }}>{tournament.desc}</Text>} {tournament.desc && <Text px="md" ta="center" size="sm" style={{ whiteSpace: 'pre-wrap' }}>{tournament.desc}</Text>}
<Card withBorder radius="lg" p="lg"> <Card
withBorder
radius="lg"
p="lg"
style={{
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">
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<UsersIcon size={16} /> <UsersIcon size={16} />
@@ -13,7 +13,7 @@ interface UnenrollTeamProps {
const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) => { const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) => {
const { open, isOpen, toggle } = useSheet(); const { open, isOpen, toggle } = useSheet();
const { mutate: unenrollTeam } = useUnenrollTeam(); const { mutate: unenrollTeam, isPending } = useUnenrollTeam();
const handleUnenrollTeam = useCallback( const handleUnenrollTeam = useCallback(
async () => { async () => {
await unenrollTeam({ tournamentId, teamId }, { await unenrollTeam({ tournamentId, teamId }, {
@@ -35,8 +35,8 @@ const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) =>
<Sheet title={"Unenroll Team"} opened={isOpen} onChange={toggle}> <Sheet title={"Unenroll Team"} opened={isOpen} onChange={toggle}>
<Stack gap='xs'> <Stack gap='xs'>
<Text size="sm" fw={500}>Are you sure you want to unenroll from this tournament? You can enroll again at any point before the deadline.</Text> <Text size="sm" fw={500}>Are you sure you want to unenroll from this tournament? You can enroll again at any point before the deadline.</Text>
<Button onClick={handleUnenrollTeam}>Confirm</Button> <Button onClick={handleUnenrollTeam} loading={isPending}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button> <Button variant="subtle" color="red" onClick={toggle} disabled={isPending}>Cancel</Button>
</Stack> </Stack>
</Sheet> </Sheet>
</> </>
+20 -9
View File
@@ -1,27 +1,38 @@
import { useAuth } from "@/contexts/auth-context"; import { useAuth } from "@/contexts/auth-context";
import { useMantineTheme } from "@mantine/core";
import { useMantineColorScheme } from "@mantine/core"; import { useMantineColorScheme } from "@mantine/core";
import { useEffect } from "react"; import { useEffect } from "react";
import { CHROME_COLORS, setThemeColorMeta } from "./theme-colors";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => { const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const { const {
metadata: { colorScheme }, metadata: { colorScheme },
} = useAuth(); } = useAuth();
const { setColorScheme } = useMantineColorScheme(); const { setColorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
useEffect(() => { useEffect(() => {
if (!colorScheme) return; if (!colorScheme) return;
setColorScheme(colorScheme); setColorScheme(colorScheme);
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) { const media = window.matchMedia("(prefers-color-scheme: dark)");
themeColorMeta.setAttribute( const applyThemeColor = () => {
"content", const resolved =
colorScheme === "dark" ? theme.colors.dark[8] : theme.colors.gray[0] colorScheme === "auto"
); ? media.matches
? "dark"
: "light"
: colorScheme;
setThemeColorMeta(CHROME_COLORS[resolved].base);
};
applyThemeColor();
if (colorScheme === "auto") {
media.addEventListener("change", applyThemeColor);
return () => media.removeEventListener("change", applyThemeColor);
} }
}, [colorScheme]); }, [colorScheme, setColorScheme]);
return children; return children;
}; };
+28 -6
View File
@@ -15,10 +15,28 @@ const commonInputStyles = {
}, },
}; };
const pressFeedbackCss = `
.flxn-press {
transition: transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
}
.flxn-press:active:not(:disabled):not([data-disabled]):not([data-loading]) {
transform: scale(0.97);
}
@media (prefers-reduced-motion: reduce) {
.flxn-press,
.flxn-press:active {
transition: none;
transform: none;
}
}
`;
const theme = createTheme({ const theme = createTheme({
defaultRadius: "sm", defaultRadius: "sm",
respectReducedMotion: true,
fontFamily: '"Inter", sans-serif', fontFamily: '"Inter", sans-serif',
headings: { fontFamily: '"League Spartan", sans-serif' }, headings: { fontFamily: '"League Spartan", sans-serif' },
activeClassName: "flxn-press",
components: { components: {
TextInput: { TextInput: {
styles: commonInputStyles, styles: commonInputStyles,
@@ -35,14 +53,15 @@ const theme = createTheme({
Autocomplete: { Autocomplete: {
styles: commonInputStyles, styles: commonInputStyles,
}, },
DateTiemPicker: { Card: {
defaultProps: {
shadow: "xs",
},
},
Title: {
styles: { styles: {
root: { root: {
zIndex: 1000, letterSpacing: "-0.01em",
},
input: {
zIndex: 1000,
backgroundColor: "red",
}, },
}, },
}, },
@@ -70,6 +89,9 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
defaultColorScheme={colorScheme} defaultColorScheme={colorScheme}
theme={themeWithPrimaryColor} theme={themeWithPrimaryColor}
> >
<style href="flxn-press-feedback" precedence="medium">
{pressFeedbackCss}
</style>
<ColorSchemeProvider>{children}</ColorSchemeProvider> <ColorSchemeProvider>{children}</ColorSchemeProvider>
</MantineProviderCore> </MantineProviderCore>
); );
+27
View File
@@ -0,0 +1,27 @@
export type ChromeColorScheme = "light" | "dark";
export const CHROME_COLORS: Record<
ChromeColorScheme,
{ base: string; dimmed: string }
> = {
light: { base: "#ffffff", dimmed: "#999999" },
dark: { base: "#242424", dimmed: "#161616" },
};
export function setThemeColorMeta(color: string): void {
if (typeof document === "undefined") return;
const metas = document.querySelectorAll<HTMLMetaElement>(
'meta[name="theme-color"]'
);
if (metas.length === 0) {
const meta = document.createElement("meta");
meta.name = "theme-color";
meta.content = color;
document.head.appendChild(meta);
return;
}
metas.forEach((meta) => meta.setAttribute("content", color));
}