Merge branch 'style' into development
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user