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 49d04f5..3d37df6 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -31,7 +31,7 @@ export function getRouter() { defaultPreload: "intent", defaultErrorComponent: DefaultCatchBoundary, scrollRestoration: true, - defaultViewTransition: false, + defaultViewTransition: true, }); setupRouterSsrQueryIntegration({ diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index ee41176..80a07cc 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' }, @@ -179,6 +179,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 41b2627..174e79a 100644 --- a/src/features/badges/components/badge-showcase.tsx +++ b/src/features/badges/components/badge-showcase.tsx @@ -57,6 +57,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); @@ -138,6 +160,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 ( { withBackButton && } - {title?.toLocaleUpperCase()} + + {title?.toLocaleUpperCase()} + ); diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx index 87fdb87..f152e3b 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 } = useRouterConfig(); @@ -37,15 +38,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 81918b5..2012241 100644 --- a/src/features/core/components/nav-link/nav-link.tsx +++ b/src/features/core/components/nav-link/nav-link.tsx @@ -35,6 +35,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 6fb4c00..82d555d 100644 --- a/src/features/core/components/pullable.tsx +++ b/src/features/core/components/pullable.tsx @@ -1,7 +1,7 @@ -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"; @@ -13,9 +13,6 @@ interface PullableProps extends PropsWithChildren { onScrollPositionChange: (position: { x: number, y: number }) => void; } -/** - * Pullable is a component that allows the user to pull down to refresh the page - */ const Pullable: React.FC = ({ children, scrollPosition, onScrollPositionChange }) => { const height = useAppShellHeight(); const [isRefreshing, setIsRefreshing] = useState(false); @@ -29,16 +26,18 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP 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) { @@ -85,7 +84,6 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP return () => void ac.abort(); }, []); - // Fix wheel scrolling over child elements useEffect(() => { const scrollWrapper = document.getElementById('scroll-wrapper'); if (!scrollWrapper) return; @@ -96,33 +94,29 @@ const Pullable: React.FC = ({ children, scrollPosition, onScrollP 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 37d3ebe..c839ef9 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -8,6 +8,7 @@ import { 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) => ( - - ))} - - {hasGrouped && ( -
- - {groupedCount} - - - - )} + + + + + ))} + + {hasGrouped && ( + + + + )} + 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. - - + + 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 2c5c382..d7abea7 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", }, }, }, @@ -65,6 +84,9 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => { defaultColorScheme={colorScheme} theme={{ ...theme, primaryColor }} > + {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)); +}