diff --git a/public/site.webmanifest b/public/site.webmanifest
index cd295fc..d7cf798 100644
--- a/public/site.webmanifest
+++ b/public/site.webmanifest
@@ -18,8 +18,8 @@
],
"start_url": "/",
"display": "standalone",
- "theme_color": "#1e293b",
- "background_color": "#0f172a",
+ "theme_color": "#242424",
+ "background_color": "#242424",
"orientation": "portrait-primary",
"scope": "/",
"categories": ["games", "social", "beer pong"],
diff --git a/public/styles.css b/public/styles.css
index 833e0b0..9dc69cf 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -28,4 +28,28 @@
[data-drawer-level="3"].drawer-scaling {
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); }
+ }
}
\ No newline at end of file
diff --git a/src/app/router.tsx b/src/app/router.tsx
index d312503..ee7b1a5 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -32,7 +32,7 @@ export function getRouter() {
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
- defaultViewTransition: false,
+ defaultViewTransition: true,
});
setupRouterSsrQueryIntegration({
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index 3c8d7e3..655ce8b 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -19,6 +19,7 @@ import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
+import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
@@ -41,11 +42,10 @@ export const Route = createRootRouteWithContext<{
{
name: "viewport",
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: 'keywords', content: 'FLXN, beer pong, tournament, sports, statistics, pong' },
- { name: 'theme-color', content: '#1e293b' },
{ property: 'og:title', content: 'FLXN' },
{ property: 'og:description', content: 'Amicus meus madidus' },
{ property: 'og:url', content: 'https://flexxon.app' },
@@ -182,6 +182,16 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
+
+
diff --git a/src/app/routes/_authed.tsx b/src/app/routes/_authed.tsx
index d024d51..b08b0cc 100644
--- a/src/app/routes/_authed.tsx
+++ b/src/app/routes/_authed.tsx
@@ -1,7 +1,7 @@
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout";
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")({
beforeLoad: ({ context }) => {
@@ -26,9 +26,21 @@ export const Route = createFileRoute("/_authed")({
},
pendingComponent: () => (
-
-
-
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
),
});
diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx
index e1ee2ba..86337ca 100644
--- a/src/app/routes/_authed/admin/activities.tsx
+++ b/src/app/routes/_authed/admin/activities.tsx
@@ -2,8 +2,8 @@ import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players";
-import { Tabs } from "@mantine/core";
-import { useState } from "react";
+import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
+import { Suspense, useState } from "react";
export const Route = createFileRoute("/_authed/admin/activities")({
component: Stats,
@@ -23,6 +23,30 @@ export const Route = createFileRoute("/_authed/admin/activities")({
}),
});
+function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
+ return (
+
+ {withSearch && (
+
+
+
+ )}
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
function Stats() {
const [activeTab, setActiveTab] = useState("server-functions");
@@ -34,11 +58,15 @@ function Stats() {
-
+ }>
+
+
-
+ }>
+
+
);
diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
index a6f02cd..79b9bd1 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
@@ -1,7 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
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 useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
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,
+ pendingComponent: AssignPartnersPending,
});
+function AssignPartnersPending() {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
interface TeamAssignment {
player1: PlayerInfo;
player2: PlayerInfo;
diff --git a/src/app/routes/_authed/admin/tournaments/$id/index.tsx b/src/app/routes/_authed/admin/tournaments/$id/index.tsx
index 6857a14..3c23e98 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/index.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/index.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
+import { Divider, Group, Skeleton, Stack } from "@mantine/core";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
withPadding: false,
}),
component: RouteComponent,
+ pendingComponent: ManageTournamentPending,
});
+function ManageTournamentPending() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
function RouteComponent() {
const { id } = Route.useParams();
return ;
diff --git a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
index f1b98ad..ced398a 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams";
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")({
beforeLoad: async ({ context, params }) => {
@@ -23,8 +24,37 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
withPadding: false,
}),
component: RouteComponent,
+ pendingComponent: ManageTeamsPending,
});
+function ManageTeamsPending() {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, index) => (
+
+ ))}
+
+
+ );
+}
+
function RouteComponent() {
const { id } = Route.useParams();
const { tournament } = Route.useRouteContext();
diff --git a/src/app/routes/_authed/admin/tournaments/index.tsx b/src/app/routes/_authed/admin/tournaments/index.tsx
index 1c69d84..1210da2 100644
--- a/src/app/routes/_authed/admin/tournaments/index.tsx
+++ b/src/app/routes/_authed/admin/tournaments/index.tsx
@@ -1,12 +1,14 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
+import { Divider, Group, Skeleton, Stack } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router";
+import { Suspense } from "react";
export const Route = createFileRoute("/_authed/admin/tournaments/")({
- beforeLoad: async ({ context }) => {
+ beforeLoad: ({ context }) => {
const { queryClient } = context;
- await prefetchServerQuery(queryClient, tournamentQueries.list());
+ prefetchServerQuery(queryClient, tournamentQueries.list());
},
loader: () => ({
header: {
@@ -19,6 +21,26 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent,
});
+function TournamentListSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ );
+}
+
function RouteComponent() {
- return ;
+ return (
+ }>
+
+
+ );
}
diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index 06674fa..a4ecd30 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
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 { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
@@ -37,8 +37,46 @@ export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({
},
}),
component: RouteComponent,
+ pendingComponent: RunTournamentPending,
});
+function RunTournamentPending() {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
diff --git a/src/app/routes/_authed/stats.tsx b/src/app/routes/_authed/stats.tsx
index 030bf57..ff0faae 100644
--- a/src/app/routes/_authed/stats.tsx
+++ b/src/app/routes/_authed/stats.tsx
@@ -64,7 +64,7 @@ function Stats() {
- }>
+ }>
diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx
index 38a18bc..91ca053 100644
--- a/src/app/routes/_authed/tournaments/$id.bracket.tsx
+++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx
@@ -4,7 +4,7 @@ import {
useTournament,
} from "@/features/tournaments/queries";
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 { BracketData } from "@/features/bracket/types";
import { Match } from "@/features/matches/types";
@@ -31,8 +31,49 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
},
}),
component: RouteComponent,
+ pendingComponent: BracketPending,
});
+function BracketPending() {
+ const columns = [4, 2, 1];
+
+ return (
+
+
+
+
+ {columns.map((count, columnIndex) => (
+
+ {Array.from({ length: count }).map((_, matchIndex) => (
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
diff --git a/src/app/routes/_authed/tournaments/$id.groups.tsx b/src/app/routes/_authed/tournaments/$id.groups.tsx
index f95ed87..f5b8696 100644
--- a/src/app/routes/_authed/tournaments/$id.groups.tsx
+++ b/src/app/routes/_authed/tournaments/$id.groups.tsx
@@ -5,7 +5,7 @@ import {
} from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
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")({
beforeLoad: async ({ context, params }) => {
@@ -28,8 +28,46 @@ export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
},
}),
component: RouteComponent,
+ pendingComponent: GroupsPending,
});
+function GroupsPending() {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
diff --git a/src/app/routes/_authed/tournaments/index.tsx b/src/app/routes/_authed/tournaments/index.tsx
index d250f0d..94cb0b0 100644
--- a/src/app/routes/_authed/tournaments/index.tsx
+++ b/src/app/routes/_authed/tournaments/index.tsx
@@ -23,7 +23,13 @@ export const Route = createFileRoute('/_authed/tournaments/')({
function RouteComponent() {
return
{Array(10).fill(null).map((_, index) => (
-
+
))}
}>
diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx
index 67dcafc..a7b5ded 100644
--- a/src/components/avatar.tsx
+++ b/src/components/avatar.tsx
@@ -98,20 +98,21 @@ const Avatar = ({
>
setIsFullscreenOpen(false)}
>
-
+
num.toString().padStart(2, '0');
+
export function Countdown({ date, label, color }: CountdownProps) {
const now = useNow();
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
- const formatTime = () => {
- const pad = (num: number) => num.toString().padStart(2, '0');
-
- if (timeLeft.days > 0) {
- return `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
- } else {
- return `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:${pad(timeLeft.seconds)}`;
- }
- };
+ const totalSecondsLeft = Math.max(
+ 0,
+ Math.floor((date.getTime() - now.getTime()) / 1000)
+ );
+ const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
+
+ const prefix =
+ timeLeft.days > 0
+ ? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
+ : `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
return (
{label && {label}:}
-
- {formatTime()}
+
+ {prefix}
+
+ {pad(timeLeft.seconds)}
+
);
}
-export default Countdown;
\ No newline at end of file
+export default Countdown;
diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx
new file mode 100644
index 0000000..1bf4b0e
--- /dev/null
+++ b/src/components/empty-state.tsx
@@ -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 (
+
+
+ {icon}
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action}
+
+ );
+};
+
+export default EmptyState;
diff --git a/src/components/glitch-avatar.tsx b/src/components/glitch-avatar.tsx
index 576eabb..d435bd8 100644
--- a/src/components/glitch-avatar.tsx
+++ b/src/components/glitch-avatar.tsx
@@ -16,6 +16,7 @@ interface GlitchAvatarProps
contain?: boolean;
children?: React.ReactNode;
px?: string | number;
+ frame?: boolean;
}
const GlitchAvatar = ({
@@ -28,6 +29,7 @@ const GlitchAvatar = ({
contain = false,
children,
px,
+ frame = false,
...props
}: GlitchAvatarProps) => {
const [showGlitch, setShowGlitch] = useState(false);
@@ -37,13 +39,16 @@ const GlitchAvatar = ({
useEffect(() => {
if (!glitchSrc) return;
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+ let timeoutId: ReturnType;
const scheduleNextGlitch = () => {
const delay = Math.random() * 10000 + 5000;
- return setTimeout(() => {
+ timeoutId = setTimeout(() => {
setShowGlitch(true);
setIsPlaying(true);
- setTimeout(() => {
+ timeoutId = setTimeout(() => {
setShowGlitch(false);
setIsPlaying(false);
scheduleNextGlitch();
@@ -51,7 +56,7 @@ const GlitchAvatar = ({
}, delay);
};
- const timeoutId = scheduleNextGlitch();
+ scheduleNextGlitch();
return () => clearTimeout(timeoutId);
}, [glitchSrc]);
@@ -94,12 +99,18 @@ const GlitchAvatar = ({
? `${radius + 8}px`
: "calc(var(--mantine-radius-md) + 8px)",
position: "relative",
+ ...(frame && {
+ boxShadow:
+ "0 0 0 1px color-mix(in srgb, var(--mantine-primary-color-filled) 35%, transparent), 0 8px 32px -8px color-mix(in srgb, var(--mantine-primary-color-filled) 30%, transparent)",
+ }),
}}
>
diff --git a/src/components/list-button.tsx b/src/components/list-button.tsx
index 07cafe5..f60187c 100644
--- a/src/components/list-button.tsx
+++ b/src/components/list-button.tsx
@@ -1,5 +1,6 @@
import { Divider, Group, Loader, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
+import styles from "./list-row.module.css";
interface ListButtonProps {
label: string;
@@ -12,6 +13,7 @@ const ListButton = ({ label, onClick, Icon, loading }: ListButtonProps) => {
return (
<>
{
return (
<>
void;
+ onClose?: () => void;
+ onExited?: () => void;
}
const Drawer: React.FC = ({
@@ -19,55 +17,25 @@ const Drawer: React.FC = ({
children,
opened,
onChange,
+ onExited,
}) => {
const colorScheme = useComputedColorScheme("light");
const contentRef = useRef(null);
- const openedRef = useRef(opened);
useEffect(() => {
- openedRef.current = opened;
- }, [opened]);
+ if (!opened) return;
- useEffect(() => {
- const appElement = document.querySelector(".app") as HTMLElement;
+ const appElement = document.querySelector(".app");
+ const colors = CHROME_COLORS[colorScheme];
- if (!appElement) return;
-
- 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;
- }
+ appElement?.classList.add("drawer-scaling");
+ setThemeColorMeta(colors.dimmed);
return () => {
- appElement.classList.remove("drawer-scaling");
- themeColorMeta.content = currentColors.normal;
+ appElement?.classList.remove("drawer-scaling");
+ setThemeColorMeta(colors.base);
};
- }, [opened]);
+ }, [opened, colorScheme]);
useEffect(() => {
if (!opened || !contentRef.current) return;
@@ -109,6 +77,9 @@ const Drawer: React.FC = ({
repositionInputs={false}
open={opened}
onOpenChange={onChange}
+ onAnimationEnd={(open) => {
+ if (!open) onExited?.();
+ }}
>
diff --git a/src/components/sheet/modal.tsx b/src/components/sheet/modal.tsx
index 19c3ff5..091b6f6 100644
--- a/src/components/sheet/modal.tsx
+++ b/src/components/sheet/modal.tsx
@@ -5,13 +5,30 @@ interface ModalProps extends PropsWithChildren {
title?: string;
opened: boolean;
onClose: () => void;
+ onChange?: (next: boolean) => void;
+ onExited?: () => void;
}
-const Modal: React.FC = ({ title, children, opened, onClose }) => (
+const Modal: React.FC = ({
+ title,
+ children,
+ opened,
+ onClose,
+ onExited,
+}) => (
{title}}
+ radius={20}
+ transitionProps={{
+ transition: "pop",
+ duration: 200,
+ timingFunction: "ease-out",
+ onExited,
+ }}
+ overlayProps={{ backgroundOpacity: 0.4 }}
+ closeButtonProps={{ "aria-label": "Close" }}
>
void;
}
+const DRAWER_EXIT_MS = 500;
+const MODAL_EXIT_MS = 200;
+const EXIT_BUFFER_MS = 100;
+
const Sheet: React.FC = ({ title, children, opened, onChange }) => {
const isMobile = useIsMobile();
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;
- if (!opened) return null;
+ if (!opened && !mounted) return null;
return (
= ({ title, children, opened, onChange }) => {
opened={opened}
onChange={onChange}
onClose={handleClose}
+ onExited={handleExited}
>
diff --git a/src/components/sheet/slide-panel/slide-panel.tsx b/src/components/sheet/slide-panel/slide-panel.tsx
index bf586c3..7ccf2e4 100644
--- a/src/components/sheet/slide-panel/slide-panel.tsx
+++ b/src/components/sheet/slide-panel/slide-panel.tsx
@@ -150,7 +150,11 @@ const SlidePanel = ({
{panelConfig && (
<>
-
+
{panelConfig.title}
@@ -158,6 +162,7 @@ const SlidePanel = ({
variant="transparent"
color="green"
onClick={handleConfirm}
+ aria-label="Confirm"
>
diff --git a/src/components/sheet/styles.module.css b/src/components/sheet/styles.module.css
index a60924d..28c5bf4 100644
--- a/src/components/sheet/styles.module.css
+++ b/src/components/sheet/styles.module.css
@@ -20,3 +20,9 @@
outline: none;
transition: height 0.2s ease-out, max-height 0.2s ease-out;
}
+
+@media (prefers-reduced-motion: reduce) {
+ .drawerContent {
+ transition: none;
+ }
+}
diff --git a/src/features/activities/components/activities-table.tsx b/src/features/activities/components/activities-table.tsx
index dbbb381..95ec56e 100644
--- a/src/features/activities/components/activities-table.tsx
+++ b/src/features/activities/components/activities-table.tsx
@@ -12,6 +12,8 @@ import {
Pagination,
Code,
Alert,
+ ThemeIcon,
+ Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
@@ -20,6 +22,7 @@ import {
CheckIcon,
XIcon,
ChecksIcon,
+ PulseIcon,
} from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries";
@@ -220,9 +223,14 @@ const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any
{result.items.length === 0 && (
-
- No activities found
-
+
+
+
+
+
+ No Activities Found
+
+
)}
{result.totalPages > 1 && (
@@ -339,7 +347,7 @@ export const ActivitiesTable = () => {
Date
@@ -355,7 +363,7 @@ export const ActivitiesTable = () => {
Duration
diff --git a/src/features/admin/components/manage-tournaments.tsx b/src/features/admin/components/manage-tournaments.tsx
index e691f1e..2df0449 100644
--- a/src/features/admin/components/manage-tournaments.tsx
+++ b/src/features/admin/components/manage-tournaments.tsx
@@ -7,7 +7,7 @@ const ManageTournaments = () => {
return (
{tournaments.map((t) => (
-
+
))}
);
diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx
index aff3ff2..5e4827b 100644
--- a/src/features/badges/components/badge-showcase.tsx
+++ b/src/features/badges/components/badge-showcase.tsx
@@ -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 { user } = useAuth();
const { data: badgeProgress } = usePlayerBadges(playerId);
@@ -139,6 +161,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
return (
+
{
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx
index 93cd177..6920fc7 100644
--- a/src/features/bracket/components/bracket-view.tsx
+++ b/src/features/bracket/components/bracket-view.tsx
@@ -4,6 +4,7 @@ import { BracketData } from "../types";
import { Bracket } from "./bracket";
import useAppShellHeight from "@/hooks/use-appshell-height";
import { Match } from "@/features/matches/types";
+import styles from "./styles.module.css";
interface BracketViewProps {
bracket: BracketData;
@@ -25,7 +26,7 @@ const BracketView: React.FC = ({ bracket, showControls, groupC
return = ({
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
});
+ editSheet.close();
},
});
@@ -149,9 +150,8 @@ export const MatchCard: React.FC = ({
matchId: match.id,
},
});
- editSheet.close();
},
- [match.id, editSheet]
+ [end, match.id]
);
const speak = useCallback((text: string): Promise => {
@@ -311,6 +311,7 @@ export const MatchCard: React.FC = ({
variant="subtle"
color="gray"
onClick={handleSpeakerClick}
+ aria-label="Announce matchup"
>
@@ -323,10 +324,12 @@ export const MatchCard: React.FC = ({
= ({
= ({
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
+ loading={end.isPending}
/>
diff --git a/src/features/bracket/components/match-form.tsx b/src/features/bracket/components/match-form.tsx
index c78af04..5a30c48 100644
--- a/src/features/bracket/components/match-form.tsx
+++ b/src/features/bracket/components/match-form.tsx
@@ -10,12 +10,14 @@ interface MatchFormProps {
ot_count: number;
}) => void;
onCancel: () => void;
+ loading?: boolean;
}
export const MatchForm: React.FC = ({
match,
onSubmit,
onCancel,
+ loading = false,
}) => {
const form = useForm({
initialValues: {
@@ -35,7 +37,6 @@ export const MatchForm: React.FC = ({
return "At least one team must have 10 cups";
}
- // Both teams can't have 10 cups
if (homeCups === 10 && awayCups === 10) {
return "Both teams cannot have 10 cups";
}
@@ -145,8 +146,15 @@ export const MatchForm: React.FC = ({
-
-
diff --git a/src/features/bracket/components/match-slot.module.css b/src/features/bracket/components/match-slot.module.css
new file mode 100644
index 0000000..8b305f9
--- /dev/null
+++ b/src/features/bracket/components/match-slot.module.css
@@ -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;
+ }
+}
diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx
index e584be5..f857db3 100644
--- a/src/features/bracket/components/match-slot.tsx
+++ b/src/features/bracket/components/match-slot.tsx
@@ -1,8 +1,10 @@
import { Flex, Text } from "@mantine/core";
-import React from "react";
+import React, { useEffect, useRef, useState } from "react";
import { CrownIcon } from "@phosphor-icons/react";
import { SeedBadge } from "./seed-badge";
import { TeamInfo } from "@/features/teams/types";
+import AnimatedScore from "@/features/matches/components/animated-score";
+import classes from "./match-slot.module.css";
interface MatchSlotProps {
from?: number;
@@ -22,60 +24,80 @@ export const MatchSlot: React.FC = ({
cups,
isWinner,
groupLabel
-}) => (
-
- {(seed && seed > 0) ? : undefined}
-
-
- {team ? (
- <>
- 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
- truncate
- style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
- >
- {team.name}
+}) => {
+ const teamId = team?.id;
+ const previousTeamIdRef = useRef(teamId);
+ const [entranceKey, setEntranceKey] = useState(0);
+
+ useEffect(() => {
+ const previousTeamId = previousTeamIdRef.current;
+ previousTeamIdRef.current = teamId;
+ if (teamId && previousTeamId !== teamId) {
+ setEntranceKey((key) => key + 1);
+ }
+ }, [teamId]);
+
+ return (
+
+ {(seed && seed > 0) ? : undefined}
+
+ 0 ? classes.slotEnter : undefined}
+ align="center"
+ gap={4}
+ flex={1}
+ >
+ {team ? (
+ <>
+ 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
+ truncate
+ style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
+ >
+ {team.name}
+
+ {isWinner && (
+
+ )}
+ >
+ ) : groupLabel ? (
+
+ {groupLabel}
- {isWinner && (
-
- )}
- >
- ) : groupLabel ? (
-
- {groupLabel}
-
- ) : from ? (
-
- {from_loser ? "Loser" : "Winner"} of Match {from}
-
- ) : (
-
- TBD
-
- )}
+ ) : from ? (
+
+ {from_loser ? "Loser" : "Winner"} of Match {from}
+
+ ) : (
+
+ TBD
+
+ )}
+
+ {
+ cups !== undefined ? (
+
+ ) : undefined
+ }
- {
- cups !== undefined ? (
- {cups}
- ) : undefined
- }
-
-);
+ );
+};
diff --git a/src/features/core/components/back-button.tsx b/src/features/core/components/back-button.tsx
index 86c1cb8..aeca060 100644
--- a/src/features/core/components/back-button.tsx
+++ b/src/features/core/components/back-button.tsx
@@ -1,4 +1,4 @@
-import { Box } from "@mantine/core"
+import { UnstyledButton } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
@@ -6,15 +6,16 @@ const BackButton = ({ top=20, left=20 }: { top?: number, left?: number }) => {
const router = useRouter()
return (
- router.history.back()}
pos='absolute'
left={left}
top={top}
>
-
+
);
}
diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx
index 257ca69..7da11f2 100644
--- a/src/features/core/components/header.tsx
+++ b/src/features/core/components/header.tsx
@@ -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 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 (
);
diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx
index c6cc6bd..592d171 100644
--- a/src/features/core/components/layout.tsx
+++ b/src/features/core/components/layout.tsx
@@ -6,6 +6,7 @@ import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
+import './route-transition.css';
const Layout: React.FC = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig();
@@ -35,15 +36,15 @@ const Layout: React.FC = ({ children }) => {
// top: viewport.top
}}
>
-
+ 2} />
diff --git a/src/features/core/components/nav-link/nav-link.tsx b/src/features/core/components/nav-link/nav-link.tsx
index d0be64a..2a0c38b 100644
--- a/src/features/core/components/nav-link/nav-link.tsx
+++ b/src/features/core/components/nav-link/nav-link.tsx
@@ -34,6 +34,8 @@ export const NavLink = ({
to={href}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
+ aria-label={label}
+ aria-current={isActive ? "page" : undefined}
>
{
const { user, roles } = useAuth()
const isMobile = useIsMobile();
- const { colorScheme } = useMantineColorScheme();
const isPWA = useIsPWA();
const links = useLinks(user?.id, roles);
- const isDark = colorScheme === 'dark';
- const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
- const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
- // boxShadow: `5px 5px ${boxShadowColor}`, borderColor
+ const bottomOffset = isPWA
+ ? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
+ : 'env(safe-area-inset-bottom, 0px)';
if (isMobile) return (
-
+
{links.map((link) => (
diff --git a/src/features/core/components/pullable.tsx b/src/features/core/components/pullable.tsx
index c80d2d4..7cccd14 100644
--- a/src/features/core/components/pullable.tsx
+++ b/src/features/core/components/pullable.tsx
@@ -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 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 useRouterConfig from "../hooks/use-router-config";
import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80;
-/**
- * Pullable is a component that allows the user to pull down to refresh the page
- */
-const Pullable: React.FC = ({ children }) => {
+interface PullableProps extends PropsWithChildren {
+ scrollPosition: { x: number, y: number };
+ onScrollPositionChange: (position: { x: number, y: number }) => void;
+}
+
+const Pullable: React.FC = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
@@ -31,16 +33,18 @@ const Pullable: React.FC = ({ children }) => {
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
+ const minSpinnerDisplay = new Promise((resolve) => setTimeout(resolve, 400));
if (refresh.length > 0) {
- // TODO: Remove this after testing - or does the delay help ux?
- await new Promise(resolve => setTimeout(resolve, 1000));
- refresh.forEach(async (queryKey) => {
- const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey];
- await queryClient.refetchQueries({ queryKey: keyArray, exact: true });
- });
+ await Promise.all(
+ refresh.map((queryKey) => {
+ const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey];
+ return queryClient.refetchQueries({ queryKey: keyArray, exact: true });
+ })
+ );
}
+ await minSpinnerDisplay;
setIsRefreshing(false);
- }, [refresh]);
+ }, [refresh, queryClient]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
@@ -87,7 +91,6 @@ const Pullable: React.FC = ({ children }) => {
return () => void ac.abort();
}, []);
- // Fix wheel scrolling over child elements
useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper');
if (!scrollWrapper) return;
@@ -98,33 +101,29 @@ const Pullable: React.FC = ({ children }) => {
const handleWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement;
- // Check if the target is inside a nested scrollable container
let element = target;
while (element && element !== viewport) {
const overflow = window.getComputedStyle(element).overflow;
const overflowY = window.getComputedStyle(element).overflowY;
const overflowX = window.getComputedStyle(element).overflowX;
- // If we found a scrollable ancestor (not the main viewport), don't interfere
if (
(overflow === 'auto' || overflow === 'scroll' ||
overflowY === 'auto' || overflowY === 'scroll' ||
overflowX === 'auto' || overflowX === 'scroll') &&
element !== viewport
) {
- // Check if this element can actually scroll in the wheel direction
const canScrollY = element.scrollHeight > element.clientHeight;
const canScrollX = element.scrollWidth > element.clientWidth;
if ((e.deltaY !== 0 && canScrollY) || (e.deltaX !== 0 && canScrollX)) {
- return; // Let the nested scroller handle it
+ return;
}
}
element = element.parentElement as HTMLElement;
}
- // No nested scroller found, scroll the main viewport
viewport.scrollTop += e.deltaY;
viewport.scrollLeft += e.deltaX;
};
diff --git a/src/features/core/components/route-transition.css b/src/features/core/components/route-transition.css
new file mode 100644
index 0000000..5f421cc
--- /dev/null
+++ b/src/features/core/components/route-transition.css
@@ -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;
+ }
+}
diff --git a/src/features/core/components/settings-button.tsx b/src/features/core/components/settings-button.tsx
index 3cde337..26f02c5 100644
--- a/src/features/core/components/settings-button.tsx
+++ b/src/features/core/components/settings-button.tsx
@@ -1,4 +1,4 @@
-import { Box } from "@mantine/core"
+import { UnstyledButton } from "@mantine/core"
import { GearIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router"
import { memo } from "react";
@@ -13,16 +13,17 @@ const SettingsButton = ({ to }: SettingButtonProps) => {
const navigate = useNavigate();
return (
- navigate({ to })}
pos='absolute'
right={20}
top={6}
>
-
+
);
}
-export default memo(SettingsButton, (prev, next) => prev.to !== next.to);
+export default memo(SettingsButton, (prev, next) => prev.to === next.to);
diff --git a/src/features/login/components/layout.tsx b/src/features/login/components/layout.tsx
index ef6a4ba..2d359d2 100644
--- a/src/features/login/components/layout.tsx
+++ b/src/features/login/components/layout.tsx
@@ -1,7 +1,7 @@
import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
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 { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
@@ -27,7 +27,8 @@ const Layout: React.FC = ({ children }) => {
style={{ transition: 'padding-top 0.1s ease' }}
>
= ({ children }) => {
= ({ children }) => {
>
- Welcome to FLXN
+
+ Welcome to FLXN
+
+ Amicus meus madidus
+
+
{children}
diff --git a/src/features/login/components/login-flow.tsx b/src/features/login/components/login-flow.tsx
index f50e489..50e4587 100644
--- a/src/features/login/components/login-flow.tsx
+++ b/src/features/login/components/login-flow.tsx
@@ -16,7 +16,7 @@ const LoginFlow = () => {
}
return
-
+
;
};
diff --git a/src/features/login/components/player-prompt/index.tsx b/src/features/login/components/player-prompt/index.tsx
index 0d0da57..9a2a4e3 100644
--- a/src/features/login/components/player-prompt/index.tsx
+++ b/src/features/login/components/player-prompt/index.tsx
@@ -88,6 +88,7 @@ const PlayerPrompt = () => {
return <>
setStage(undefined)}
style={{
position: 'absolute',
diff --git a/src/features/matches/components/animated-score.module.css b/src/features/matches/components/animated-score.module.css
new file mode 100644
index 0000000..09a8493
--- /dev/null
+++ b/src/features/matches/components/animated-score.module.css
@@ -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;
+ }
+}
diff --git a/src/features/matches/components/animated-score.tsx b/src/features/matches/components/animated-score.tsx
new file mode 100644
index 0000000..9e370c1
--- /dev/null
+++ b/src/features/matches/components/animated-score.tsx
@@ -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(value);
+ const [pulseKey, setPulseKey] = useState(0);
+
+ useEffect(() => {
+ if (previousValueRef.current !== value) {
+ previousValueRef.current = value;
+ setPulseKey((key) => key + 1);
+ }
+ }, [value]);
+
+ return (
+
+ 0 ? `${classes.value} ${classes.pulse}` : classes.value
+ }
+ >
+ {value}
+
+
+ );
+};
+
+export default AnimatedScore;
diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx
index d86c35e..74fb2c8 100644
--- a/src/features/matches/components/match-card.tsx
+++ b/src/features/matches/components/match-card.tsx
@@ -8,6 +8,7 @@ import { memo, Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
+import AnimatedScore from "./animated-score";
interface MatchCardProps {
match: Match;
@@ -146,14 +147,13 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
))}
-
- {match.home_cups}
-
+ value={match.home_cups}
+ />
@@ -192,14 +192,13 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
))}
-
- {match.away_cups}
-
+ value={match.away_cups}
+ />
diff --git a/src/features/players/components/player-stats-table.tsx b/src/features/players/components/player-stats-table.tsx
index e362c43..880ee3e 100644
--- a/src/features/players/components/player-stats-table.tsx
+++ b/src/features/players/components/player-stats-table.tsx
@@ -330,7 +330,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("mmr")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
-
+
MMR
{getSortIcon("mmr")}
@@ -340,7 +340,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("wins")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
-
+
Wins
{getSortIcon("wins")}
@@ -350,14 +350,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
onClick={() => handleSort("matches")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
-
+
Matches
{getSortIcon("matches")}
-
+
diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx
index 284059b..80f7904 100644
--- a/src/features/players/components/profile/index.tsx
+++ b/src/features/players/components/profile/index.tsx
@@ -30,7 +30,7 @@ const StatsWithFilter = ({ id }: { id: string }) => {
- }>
+ }>
@@ -39,8 +39,8 @@ const StatsWithFilter = ({ id }: { id: string }) => {
};
const StatsContent = ({ id, viewType }: { id: string; viewType: 'all' | 'mainline' | 'regional' }) => {
- const { data: stats, isLoading: statsLoading } = usePlayerStats(id, viewType);
- return ;
+ const { data: stats } = usePlayerStats(id, viewType);
+ return ;
};
const Profile = ({ id }: ProfileProps) => {
diff --git a/src/features/players/components/profile/skeleton.tsx b/src/features/players/components/profile/skeleton.tsx
index 32a3518..ca4c25e 100644
--- a/src/features/players/components/profile/skeleton.tsx
+++ b/src/features/players/components/profile/skeleton.tsx
@@ -1,31 +1,75 @@
-import { Box, Flex, Loader } from "@mantine/core";
-import Header from "./header";
+import { Box, Divider, Group, Paper, Skeleton, Stack } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
-import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
-import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
-import MatchList from "@/features/matches/components/match-list";
+import { StatsSkeleton } from "@/components/stats-overview";
+import BadgeShowcaseSkeleton from "@/features/badges/components/badge-showcase-skeleton";
import HeaderSkeleton from "./header-skeleton";
-const SkeletonLoader = () => (
-
-
-
-)
+const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
+
+
+
+ {[0, 1].map((row) => (
+
+
+
+
+
+
+
+ ))}
+
+
+);
+
+const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+);
+
+const OverviewSkeleton = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
- content: ,
+ content: ,
},
{
label: "Matches",
- content: ,
+ content: ,
},
{
label: "Teams",
- content: ,
+ content: ,
},
];
diff --git a/src/features/reactions/components/emoji-bar.module.css b/src/features/reactions/components/emoji-bar.module.css
new file mode 100644
index 0000000..c7e2d16
--- /dev/null
+++ b/src/features/reactions/components/emoji-bar.module.css
@@ -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;
+ }
+}
diff --git a/src/features/reactions/components/emoji-bar.tsx b/src/features/reactions/components/emoji-bar.tsx
index f1acac9..cd4810b 100644
--- a/src/features/reactions/components/emoji-bar.tsx
+++ b/src/features/reactions/components/emoji-bar.tsx
@@ -2,31 +2,64 @@ import {
Group,
Button,
Text,
+ TextProps,
Stack,
ScrollArea,
Paper,
} from "@mantine/core";
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 PlayerList from "@/features/players/components/player-list";
import EmojiPicker from "./emoji-picker";
import { useMatchReactions, useToggleMatchReaction } from "../queries";
import { useAuth } from "@/contexts/auth-context";
import { Reaction } from "@/features/matches/server";
+import classes from "./emoji-bar.module.css";
interface EmojiBarProps {
matchId: string;
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 (
+
+
+ {value}
+
+
+ );
+};
+
const EmojiBar = ({
matchId,
onReactionPress,
}: EmojiBarProps) => {
const { user } = useAuth();
const { data: reactions } = useMatchReactions(matchId);
- const toggleReaction = useToggleMatchReaction(matchId);
+ const toggleReaction = useToggleMatchReaction(matchId, user);
+ const reduceMotion = useReducedMotion();
const [opened, { open, close }] = useDisclosure(false);
const [selectedEmoji, setSelectedEmoji] = useState(null);
@@ -71,87 +104,96 @@ const EmojiBar = ({
const groupedCount = groupedReactions.reduce((sum, r) => sum + r.count, 0);
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 (
<>
- {visibleReactions.map((reaction) => (
- handleLongPressStart(reaction.emoji)}
- onMouseUp={handleLongPressEnd}
- onMouseLeave={handleLongPressEnd}
- onTouchStart={() => handleLongPressStart(reaction.emoji)}
- onTouchEnd={handleLongPressEnd}
- onClick={() => handleReactionClick(reaction.emoji)}
- style={{
- userSelect: "none",
- WebkitUserSelect: "none",
- MozUserSelect: "none",
- msUserSelect: "none",
- }}
- >
-
-
- {reaction.emoji}
-
-
- {reaction.count}
-
-
-
- ))}
-
- {hasGrouped && (
- 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",
- }}
- >
-
-
- {groupedReactions.slice(0, 2).map((reaction) => (
-
{reaction.emoji}
- ))}
- {groupedReactions.length > 2 && (
-
- +{groupedReactions.length - 2}
+
+ {visibleReactions.map((reaction) => (
+
+ handleLongPressStart(reaction.emoji)}
+ onMouseUp={handleLongPressEnd}
+ onMouseLeave={handleLongPressEnd}
+ onTouchStart={() => handleLongPressStart(reaction.emoji)}
+ onTouchEnd={handleLongPressEnd}
+ onClick={() => handleReactionClick(reaction.emoji)}
+ style={{
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ MozUserSelect: "none",
+ msUserSelect: "none",
+ }}
+ >
+
+
+ {reaction.emoji}
- )}
-
-
- {groupedCount}
-
-
-
- )}
+
+
+
+
+ ))}
+
+ {hasGrouped && (
+
+ 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",
+ }}
+ >
+
+
+ {groupedReactions.slice(0, 2).map((reaction) => (
+ {reaction.emoji}
+ ))}
+ {groupedReactions.length > 2 && (
+
+ +{groupedReactions.length - 2}
+
+ )}
+
+
+
+
+
+ )}
+
toggleReaction.mutate({ data: { matchId, emoji } }))}
@@ -175,9 +217,7 @@ const EmojiBar = ({
>
{reaction.emoji}
-
- {reaction.count}
-
+
))}
diff --git a/src/features/reactions/queries.ts b/src/features/reactions/queries.ts
index 485b1f1..bd87264 100644
--- a/src/features/reactions/queries.ts
+++ b/src/features/reactions/queries.ts
@@ -1,6 +1,13 @@
-import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
-import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
-import { useQueryClient } from "@tanstack/react-query";
+import {
+ useOptimisticMutation,
+ useServerSuspenseQuery,
+} from "@/lib/tanstack-query/hooks";
+import {
+ getMatchReactions,
+ toggleMatchReaction,
+ Reaction,
+} from "@/features/matches/server";
+import { PlayerInfo } from "@/features/players/types";
export const reactionKeys = {
match: (matchId: string) => ['reactions', 'match', matchId] as const,
@@ -16,15 +23,58 @@ export const reactionQueries = {
export const useMatchReactions = (matchId: string) =>
useServerSuspenseQuery(reactionQueries.match(matchId));
-export const useToggleMatchReaction = (matchId: string) => {
- const queryClient = useQueryClient();
+const toggleReactionInList = (
+ 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,
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: reactionKeys.match(matchId)
- });
+ queryKey: reactionKeys.match(matchId),
+ optimisticUpdate: (oldData: Reaction[] | undefined, variables) => {
+ if (!oldData || !user) return oldData;
+ return toggleReactionInList(oldData, variables.data.emoji, user);
},
});
-};
diff --git a/src/features/teams/components/team-profile/index.tsx b/src/features/teams/components/team-profile/index.tsx
index 9517a91..3a5e4a2 100644
--- a/src/features/teams/components/team-profile/index.tsx
+++ b/src/features/teams/components/team-profile/index.tsx
@@ -2,10 +2,11 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
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 MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list";
+import { MatchListSkeleton } from "./skeleton";
interface ProfileProps {
id: string;
@@ -13,7 +14,7 @@ interface ProfileProps {
const TeamProfile = ({ id }: ProfileProps) => {
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);
if (!team) return Team not found;
@@ -28,13 +29,21 @@ const TeamProfile = ({ id }: ProfileProps) => {
Statistics
-
+ {statsLoading ? (
+
+ ) : (
+
+ )}
>,
},
{
label: "Matches",
- content: ,
+ content: matchesLoading ? (
+
+ ) : (
+
+ ),
},
{
label: "Tournaments",
diff --git a/src/features/teams/components/team-profile/skeleton.tsx b/src/features/teams/components/team-profile/skeleton.tsx
index 2bf6fa6..af729e5 100644
--- a/src/features/teams/components/team-profile/skeleton.tsx
+++ b/src/features/teams/components/team-profile/skeleton.tsx
@@ -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 TournamentList from "@/features/tournaments/components/tournament-list";
+import { StatsSkeleton } from "@/components/stats-overview";
import HeaderSkeleton from "./header-skeleton";
-const SkeletonLoader = () => (
-
-
-
-)
+const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
+
+
+
+ {[0, 1].map((row) => (
+
+
+
+
+
+
+
+ ))}
+
+
+);
+
+export const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+);
+
+const PlayerListSkeleton = ({ count = 2 }: { count?: number }) => (
+
+ {Array.from({ length: count }).map((_, index) => (
+ }
+ >
+
+
+ ))}
+
+);
+
+export const OverviewSkeleton = () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+);
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
- content: ,
+ content: ,
},
{
label: "Matches",
- content: ,
+ content: ,
},
{
label: "Tournaments",
- content: ,
+ content: ,
},
];
diff --git a/src/features/tournaments/components/group-match-card.tsx b/src/features/tournaments/components/group-match-card.tsx
index 75f25e8..e277389 100644
--- a/src/features/tournaments/components/group-match-card.tsx
+++ b/src/features/tournaments/components/group-match-card.tsx
@@ -10,6 +10,7 @@ import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query";
import { MatchForm } from "@/features/bracket/components/match-form";
import TeamAvatar from "@/components/team-avatar";
+import AnimatedScore from "@/features/matches/components/animated-score";
interface GroupMatchCardProps {
match: Match;
@@ -104,14 +105,13 @@ const GroupMatchCard: React.FC = ({ match, showControls })
{isEnded && match.home_cups !== undefined && (
-
- {match.home_cups}
-
+ />
)}
@@ -135,14 +135,13 @@ const GroupMatchCard: React.FC = ({ match, showControls })
{isEnded && match.away_cups !== undefined && (
-
- {match.away_cups}
-
+ />
)}
@@ -152,6 +151,7 @@ const GroupMatchCard: React.FC = ({ match, showControls })
{showStartButton && (
= ({ match, showControls })
{showEditButton && (
= ({ match, showControls })
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
+ loading={end.isPending}
/>
)}
diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx
index 8816dc4..26193ce 100644
--- a/src/features/tournaments/components/group-stage-view.tsx
+++ b/src/features/tournaments/components/group-stage-view.tsx
@@ -367,7 +367,11 @@ const GroupStageView: React.FC = ({
Standings ({standings.length})
-
+
{expandedTeams[group.id] ? : }
diff --git a/src/features/tournaments/components/podium.tsx b/src/features/tournaments/components/podium.tsx
index 8674957..6610b0b 100644
--- a/src/features/tournaments/components/podium.tsx
+++ b/src/features/tournaments/components/podium.tsx
@@ -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 { TeamInfo } from "@/features/teams/types";
import { Tournament } from "../types";
interface PodiumProps {
tournament: Tournament;
}
+type PodiumTier = "first" | "second" | "third";
+
+const tierStyles: Record = {
+ 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: ,
+ 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: ,
+ 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: ,
+ iconSize: "lg",
+ padding: "xs",
+ nameSize: "sm",
+ },
+};
+
+const PodiumRow = ({ team, tier }: { team: TeamInfo; tier: PodiumTier }) => {
+ const config = tierConfig[tier];
+
+ return (
+
+
+ {config.icon}
+
+
+
+ {team.name}
+
+
+ {team.players?.map((player) => (
+
+ {player.first_name} {player.last_name}
+
+ ))}
+
+
+
+ {config.label}
+
+
+ );
+};
+
export const Podium = ({ tournament }: PodiumProps) => {
if (!tournament.first_place) return;
return (
{tournament.first_place && (
-
-
-
-
-
-
- {tournament.first_place.name}
-
-
- {tournament.first_place.players?.map((player) => (
-
- {player.first_name} {player.last_name}
-
- ))}
-
-
-
+
)}
-
{tournament.second_place && (
-
-
-
-
-
-
- {tournament.second_place.name}
-
-
- {tournament.second_place.players?.map((player) => (
-
- {player.first_name} {player.last_name}
-
- ))}
-
-
-
+
)}
-
{tournament.third_place && (
-
-
-
-
-
-
- {tournament.third_place.name}
-
-
- {tournament.third_place.players?.map((player) => (
-
- {player.first_name} {player.last_name}
-
- ))}
-
-
-
+
)}
);
diff --git a/src/features/tournaments/components/profile/skeleton.tsx b/src/features/tournaments/components/profile/skeleton.tsx
index afee881..306fd67 100644
--- a/src/features/tournaments/components/profile/skeleton.tsx
+++ b/src/features/tournaments/components/profile/skeleton.tsx
@@ -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 TeamList from "@/features/teams/components/team-list";
import HeaderSkeleton from "./header-skeleton";
-const SkeletonLoader = () => (
-
-
-
-)
+const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
+
+
+
+ {[0, 1].map((row) => (
+
+
+
+
+
+
+
+ ))}
+
+
+);
+
+const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
+
+ {Array.from({ length: count }).map((_, index) => (
+
+ ))}
+
+);
+
+const ResultRowSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+
+
+
+);
+
+const OverviewSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+
+ {index < 3 && }
+
+ ))}
+
+
+);
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
- content: ,
+ content: ,
},
{
label: "Matches",
- content: ,
+ content: ,
},
{
label: "Teams",
- content: ,
+ content: ,
},
];
diff --git a/src/features/tournaments/components/tournament-card-list.tsx b/src/features/tournaments/components/tournament-card-list.tsx
index 2975f6a..20f2830 100644
--- a/src/features/tournaments/components/tournament-card-list.tsx
+++ b/src/features/tournaments/components/tournament-card-list.tsx
@@ -2,10 +2,11 @@ import { useAuth } from "@/contexts/auth-context";
import { useTournaments } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import { Button, Stack } from "@mantine/core";
-import { PlusIcon } from "@phosphor-icons/react";
+import { PlusIcon, TrophyIcon } from "@phosphor-icons/react";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { TournamentCard } from "./tournament-card";
+import EmptyState from "@/components/empty-state";
const TournamentCardList = () => {
const { data: tournaments } = useTournaments();
@@ -27,9 +28,17 @@ const TournamentCardList = () => {
>
) : null}
- {tournaments?.map((tournament: any) => (
-
- ))}
+ {tournaments && tournaments.length > 0 ? (
+ tournaments.map((tournament: any) => (
+
+ ))
+ ) : (
+ }
+ title="No Tournaments Yet"
+ description="The cups are racked and waiting. Check back soon."
+ />
+ )}
);
};
diff --git a/src/features/tournaments/components/tournament-card.tsx b/src/features/tournaments/components/tournament-card.tsx
index 88c864d..ea168ee 100644
--- a/src/features/tournaments/components/tournament-card.tsx
+++ b/src/features/tournaments/components/tournament-card.tsx
@@ -20,6 +20,27 @@ interface TournamentCardProps {
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) => {
const navigate = useNavigate();
@@ -29,15 +50,15 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
style={{ borderRadius: "var(--mantine-radius-md)" }}
>
+
diff --git a/src/features/tournaments/components/tournament-list.tsx b/src/features/tournaments/components/tournament-list.tsx
index 59a1737..1a2af21 100644
--- a/src/features/tournaments/components/tournament-list.tsx
+++ b/src/features/tournaments/components/tournament-list.tsx
@@ -2,7 +2,7 @@ import { List, ListItem, Divider, Skeleton, Text, Group, Box, ThemeIcon, Stack }
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
import { TournamentInfo } from "../types";
-import { useCallback } from "react";
+import { Fragment, useCallback } from "react";
import React from "react";
import { TrophyIcon, CalendarIcon, MapPinIcon } from "@phosphor-icons/react";
@@ -99,9 +99,8 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
return (
{tournaments.map((tournament) => (
- <>
+
- >
+
))}
);
diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
index 804d5a0..68e9092 100644
--- a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx
@@ -98,7 +98,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
-
+
{agent.player?.first_name} {agent.player?.last_name}
@@ -107,6 +107,7 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }
copyToClipboard(agent.phone!)}
style={{ cursor: 'pointer' }}
>
diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx
index 48753fb..334bb33 100644
--- a/src/features/tournaments/components/upcoming-tournament/index.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/index.tsx
@@ -55,7 +55,16 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{tournament.desc && {tournament.desc}}
-
+
diff --git a/src/features/tournaments/components/upcoming-tournament/unenroll-team.tsx b/src/features/tournaments/components/upcoming-tournament/unenroll-team.tsx
index 51e07a7..727899b 100644
--- a/src/features/tournaments/components/upcoming-tournament/unenroll-team.tsx
+++ b/src/features/tournaments/components/upcoming-tournament/unenroll-team.tsx
@@ -13,7 +13,7 @@ interface UnenrollTeamProps {
const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) => {
const { open, isOpen, toggle } = useSheet();
- const { mutate: unenrollTeam } = useUnenrollTeam();
+ const { mutate: unenrollTeam, isPending } = useUnenrollTeam();
const handleUnenrollTeam = useCallback(
async () => {
await unenrollTeam({ tournamentId, teamId }, {
@@ -35,8 +35,8 @@ const UnenrollTeam = ({ tournamentId, teamId, onSubmit }: UnenrollTeamProps) =>
Are you sure you want to unenroll from this tournament? You can enroll again at any point before the deadline.
- Confirm
- Cancel
+ Confirm
+ Cancel
>
diff --git a/src/lib/mantine/color-scheme-provider.tsx b/src/lib/mantine/color-scheme-provider.tsx
index 8512806..7f63126 100644
--- a/src/lib/mantine/color-scheme-provider.tsx
+++ b/src/lib/mantine/color-scheme-provider.tsx
@@ -1,27 +1,38 @@
import { useAuth } from "@/contexts/auth-context";
-import { useMantineTheme } from "@mantine/core";
import { useMantineColorScheme } from "@mantine/core";
import { useEffect } from "react";
+import { CHROME_COLORS, setThemeColorMeta } from "./theme-colors";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const {
metadata: { colorScheme },
} = useAuth();
const { setColorScheme } = useMantineColorScheme();
- const theme = useMantineTheme();
useEffect(() => {
if (!colorScheme) return;
setColorScheme(colorScheme);
- const themeColorMeta = document.querySelector('meta[name="theme-color"]');
- if (themeColorMeta) {
- themeColorMeta.setAttribute(
- "content",
- colorScheme === "dark" ? theme.colors.dark[8] : theme.colors.gray[0]
- );
+
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const applyThemeColor = () => {
+ const resolved =
+ 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;
};
diff --git a/src/lib/mantine/mantine-provider.tsx b/src/lib/mantine/mantine-provider.tsx
index a322302..1a28f5a 100644
--- a/src/lib/mantine/mantine-provider.tsx
+++ b/src/lib/mantine/mantine-provider.tsx
@@ -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({
defaultRadius: "sm",
+ respectReducedMotion: true,
fontFamily: '"Inter", sans-serif',
headings: { fontFamily: '"League Spartan", sans-serif' },
+ activeClassName: "flxn-press",
components: {
TextInput: {
styles: commonInputStyles,
@@ -35,14 +53,15 @@ const theme = createTheme({
Autocomplete: {
styles: commonInputStyles,
},
- DateTiemPicker: {
+ Card: {
+ defaultProps: {
+ shadow: "xs",
+ },
+ },
+ Title: {
styles: {
root: {
- zIndex: 1000,
- },
- input: {
- zIndex: 1000,
- backgroundColor: "red",
+ letterSpacing: "-0.01em",
},
},
},
@@ -70,6 +89,9 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
defaultColorScheme={colorScheme}
theme={themeWithPrimaryColor}
>
+
{children}
);
diff --git a/src/lib/mantine/theme-colors.ts b/src/lib/mantine/theme-colors.ts
new file mode 100644
index 0000000..c0e7054
--- /dev/null
+++ b/src/lib/mantine/theme-colors.ts
@@ -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(
+ '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));
+}