Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4420e4d0fe | ||
|
|
3cc16bdeb2 | ||
|
|
48eb7d71b2 | ||
|
|
4f81f3c0ca | ||
|
|
778f0f7994 |
@@ -2,7 +2,7 @@ FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lockb* ./
|
||||
COPY package.json bun.lock ./
|
||||
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
@@ -21,7 +21,6 @@ EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV NITRO_PORT=3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
@@ -5,65 +5,53 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mantine/carousel": "^8.2.4",
|
||||
"@mantine/charts": "^8.2.4",
|
||||
"@mantine/core": "^8.2.4",
|
||||
"@mantine/dates": "^8.2.4",
|
||||
"@mantine/form": "^8.2.4",
|
||||
"@mantine/hooks": "^8.2.4",
|
||||
"@mantine/tiptap": "^8.2.4",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@svgmoji/noto": "^3.2.0",
|
||||
"@tanstack/react-devtools": "^0.7.6",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query-devtools": "^5.66.0",
|
||||
"@tanstack/react-router": "^1.143.6",
|
||||
"@tanstack/react-router-devtools": "^1.143.6",
|
||||
"@tanstack/react-router-ssr-query": "^1.143.6",
|
||||
"@tanstack/react-start": "^1.143.6",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"@tanstack/react-router": "^1.170.17",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.167.1",
|
||||
"@tanstack/react-start": "^1.168.27",
|
||||
"@tiptap/pm": "^3.4.3",
|
||||
"@tiptap/react": "^3.4.3",
|
||||
"@tiptap/starter-kit": "^3.4.3",
|
||||
"@types/bun": "^1.2.22",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dotenv": "^17.2.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"facehash": "^0.0.7",
|
||||
"framer-motion": "^12.23.12",
|
||||
"ioredis": "^5.7.0",
|
||||
"pg": "^8.16.3",
|
||||
"pocketbase": "^0.26.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-scan": "^0.4.3",
|
||||
"react-use-draggable-scroll": "^0.4.7",
|
||||
"recharts": "^3.1.2",
|
||||
"redaxios": "^0.5.1",
|
||||
"sonner": "^2.0.7",
|
||||
"supertokens-node": "^23.0.1",
|
||||
"supertokens-web-js": "^0.15.0",
|
||||
"twilio": "^5.8.0",
|
||||
"vaul": "^1.1.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.0.15",
|
||||
"zustand": "^5.0.7"
|
||||
"zod": "^4.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-plugin": "^1.132.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
@@ -71,8 +59,7 @@
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"workbox-window": "^7.4.0"
|
||||
"workbox-build": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 31 KiB |
@@ -29,3 +29,27 @@
|
||||
[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); }
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,10 @@ async function initializeStaticRoutes(
|
||||
gz,
|
||||
etag,
|
||||
type: metadata.type,
|
||||
immutable: true,
|
||||
// Only Vite's content-hashed output under /assets/ is safe to
|
||||
// cache immutably; unhashed files (favicon, manifest, images)
|
||||
// can change between deploys and must revalidate.
|
||||
immutable: route.startsWith('/assets/'),
|
||||
size: bytes.byteLength,
|
||||
}
|
||||
routes[route] = createResponseHandler(asset)
|
||||
@@ -529,14 +532,9 @@ async function initializeServer() {
|
||||
// Fallback to TanStack Start handler for all other routes
|
||||
'/*': async (req: Request) => {
|
||||
try {
|
||||
const h3Response = await handler.fetch(req)
|
||||
|
||||
const body = await h3Response.arrayBuffer()
|
||||
return new Response(body, {
|
||||
status: h3Response.status,
|
||||
statusText: h3Response.statusText,
|
||||
headers: h3Response.headers,
|
||||
})
|
||||
// Return the handler's Response as-is so streaming bodies
|
||||
// (SSR streaming, SSE event streams) pass through unbuffered.
|
||||
return await handler.fetch(req)
|
||||
} catch (error) {
|
||||
log.error(`Server handler error: ${String(error)}`)
|
||||
return new Response('Internal Server Error', { status: 500 })
|
||||
|
||||
@@ -29,9 +29,10 @@ export function getRouter() {
|
||||
fullWidth: false,
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 60_000,
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: false,
|
||||
defaultViewTransition: true,
|
||||
});
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
|
||||
@@ -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' },
|
||||
@@ -153,6 +153,9 @@ export const Route = createRootRouteWithContext<{
|
||||
function RootComponent() {
|
||||
React.useEffect(() => {
|
||||
ensureSuperTokensFrontend();
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -179,6 +182,16 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: light)"
|
||||
content={CHROME_COLORS.light.base}
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
content={CHROME_COLORS.dark.base}
|
||||
/>
|
||||
<ColorSchemeScript />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
|
||||
@@ -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: () => (
|
||||
<Layout>
|
||||
<Flex w='100%' h="40dvh" justify="center" align="flex-end">
|
||||
<Loader size='xl' />
|
||||
</Flex>
|
||||
<Stack gap="md" p="md" w="100%">
|
||||
<Group gap="sm">
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={24} width="45%" radius="sm" />
|
||||
</Group>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`authed-pending-${index}`}
|
||||
height={96}
|
||||
w="100%"
|
||||
radius="md"
|
||||
style={{ opacity: Math.max(1 - index * 0.18, 0.3) }}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Layout>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{withSearch && (
|
||||
<Box px="md" pb="sm">
|
||||
<Skeleton height={42} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`activity-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%" justify="space-between">
|
||||
<Stack gap={6}>
|
||||
<Skeleton height={14} width={160} radius="sm" />
|
||||
<Skeleton height={10} width={100} radius="sm" />
|
||||
</Stack>
|
||||
<Skeleton height={10} width={60} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Stats() {
|
||||
const [activeTab, setActiveTab] = useState<string | null>("server-functions");
|
||||
|
||||
@@ -34,11 +58,15 @@ function Stats() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="server-functions">
|
||||
<ActivitiesTable />
|
||||
<Suspense fallback={<ActivityRowsSkeleton withSearch />}>
|
||||
<ActivitiesTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="player-activity">
|
||||
<PlayersActivityTable />
|
||||
<Suspense fallback={<ActivityRowsSkeleton />}>
|
||||
<PlayersActivityTable />
|
||||
</Suspense>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="baseline">
|
||||
<Skeleton height={28} width={36} radius="sm" />
|
||||
<Skeleton height={14} width={110} radius="sm" />
|
||||
</Group>
|
||||
<Skeleton height={36} w="100%" radius="sm" style={{ opacity: 0.7 }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface TeamAssignment {
|
||||
player1: PlayerInfo;
|
||||
player2: PlayerInfo;
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`manage-tournament-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.14, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={20} width={20} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
return <ManageTournament tournamentId={id} />;
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap="xs">
|
||||
<Box px="md">
|
||||
<Skeleton height={42} radius="sm" />
|
||||
</Box>
|
||||
<Box px="md">
|
||||
<Skeleton height={12} width={90} radius="sm" />
|
||||
</Box>
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={`manage-teams-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.1, 0.35) }}>
|
||||
<Group p="xs" wrap="nowrap" w="100%">
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={16} width="45%" radius="sm" />
|
||||
<Stack ml="auto" gap={6} align="flex-end">
|
||||
<Skeleton height={10} width={90} radius="sm" />
|
||||
<Skeleton height={10} width={70} radius="sm" />
|
||||
</Stack>
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { tournament } = Route.useRouteContext();
|
||||
|
||||
@@ -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 (
|
||||
<Stack gap={0}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={`manage-tournaments-skeleton-${index}`} style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}>
|
||||
<Group p="md" wrap="nowrap" w="100%">
|
||||
<Skeleton height={16} width="55%" radius="sm" />
|
||||
<Skeleton ml="auto" height={20} width={20} radius="sm" />
|
||||
</Group>
|
||||
<Divider />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
return <ManageTournaments />;
|
||||
return (
|
||||
<Suspense fallback={<TournamentListSkeleton />}>
|
||||
<ManageTournaments />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||
import 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 (
|
||||
<Container size="md" px={0}>
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap={0} grow mb="md">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`run-pending-tab-${index}`}
|
||||
height={44}
|
||||
radius="sm"
|
||||
style={{ opacity: 0.85 - index * 0.1 }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<Group justify="space-between" p="sm">
|
||||
<Skeleton height={16} width={120} radius="sm" />
|
||||
<Skeleton height={22} width={22} radius="xl" />
|
||||
</Group>
|
||||
</Card>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`run-pending-match-${index}`}
|
||||
height={100}
|
||||
radius="md"
|
||||
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
@@ -64,7 +64,7 @@ function Stats() {
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||
<Suspense key={deferredViewType} fallback={<PlayerStatsTableSkeleton hideFilters />}>
|
||||
<Suspense fallback={<PlayerStatsTableSkeleton hideFilters />}>
|
||||
<PlayerStatsTable viewType={deferredViewType} />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
@@ -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 (
|
||||
<Container size="md" px={0}>
|
||||
<Box
|
||||
p={0}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
minHeight: "70dvh",
|
||||
}}
|
||||
>
|
||||
<Skeleton height={18} width={140} radius="sm" m={16} />
|
||||
<Flex gap="xl" px={16} align="stretch">
|
||||
{columns.map((count, columnIndex) => (
|
||||
<Stack
|
||||
key={`bracket-pending-round-${columnIndex}`}
|
||||
gap="xl"
|
||||
justify="space-around"
|
||||
style={{ opacity: 1 - columnIndex * 0.25 }}
|
||||
>
|
||||
{Array.from({ length: count }).map((_, matchIndex) => (
|
||||
<Skeleton
|
||||
key={`bracket-pending-match-${columnIndex}-${matchIndex}`}
|
||||
height={84}
|
||||
width={220}
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
@@ -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 (
|
||||
<Container size="md" px={0}>
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap={0} grow mb="md">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`groups-pending-tab-${index}`}
|
||||
height={44}
|
||||
radius="sm"
|
||||
style={{ opacity: 0.85 - index * 0.1 }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<Group justify="space-between" p="sm">
|
||||
<Skeleton height={16} width={120} radius="sm" />
|
||||
<Skeleton height={22} width={22} radius="xl" />
|
||||
</Group>
|
||||
</Card>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={`groups-pending-match-${index}`}
|
||||
height={100}
|
||||
radius="md"
|
||||
style={{ opacity: Math.max(1 - index * 0.12, 0.4) }}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
@@ -23,7 +23,13 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
function RouteComponent() {
|
||||
return <Suspense fallback={<Stack gap="md">
|
||||
{Array(10).fill(null).map((_, index) => (
|
||||
<Skeleton height="120px" w="100%" />
|
||||
<Skeleton
|
||||
key={`tournament-card-skeleton-${index}`}
|
||||
height="120px"
|
||||
w="100%"
|
||||
radius="md"
|
||||
style={{ opacity: Math.max(1 - index * 0.08, 0.35) }}
|
||||
/>
|
||||
))}
|
||||
</Stack>}>
|
||||
<TournamentCardList />
|
||||
|
||||
@@ -98,20 +98,21 @@ const Avatar = ({
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
variant="white"
|
||||
color="dark"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
aria-label="Close image preview"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: -10,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
onClick={() => setIsFullscreenOpen(false)}
|
||||
>
|
||||
<XIcon size={18} color="white" />
|
||||
<XIcon size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<Image
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.time {
|
||||
transition: color 300ms ease-out;
|
||||
}
|
||||
|
||||
.seconds {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tick {
|
||||
animation: digit-tick 200ms ease-out;
|
||||
}
|
||||
|
||||
.finalTick {
|
||||
animation: final-tick 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes digit-tick {
|
||||
from {
|
||||
opacity: 0.2;
|
||||
transform: translateY(-0.12em);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes final-tick {
|
||||
from {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tick,
|
||||
.finalTick {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import useNow from '@/hooks/use-now';
|
||||
import { Text, Group } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import classes from './countdown.module.css';
|
||||
|
||||
interface CountdownProps {
|
||||
date: Date;
|
||||
@@ -30,25 +31,42 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
|
||||
};
|
||||
}
|
||||
|
||||
const pad = (num: number) => 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');
|
||||
const totalSecondsLeft = Math.max(
|
||||
0,
|
||||
Math.floor((date.getTime() - now.getTime()) / 1000)
|
||||
);
|
||||
const isFinalStretch = totalSecondsLeft > 0 && totalSecondsLeft <= 10;
|
||||
|
||||
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 prefix =
|
||||
timeLeft.days > 0
|
||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{label && <Text size='sm' fw={500}>{label}:</Text>}
|
||||
<Text size='sm' fw={600} c={color} ff="monospace">
|
||||
{formatTime()}
|
||||
<Text
|
||||
size='sm'
|
||||
fw={600}
|
||||
c={isFinalStretch ? 'red' : color}
|
||||
ff="monospace"
|
||||
className={classes.time}
|
||||
>
|
||||
{prefix}
|
||||
<span
|
||||
key={timeLeft.seconds}
|
||||
className={`${classes.seconds} ${
|
||||
isFinalStretch ? classes.finalTick : classes.tick
|
||||
}`}
|
||||
>
|
||||
{pad(timeLeft.seconds)}
|
||||
</span>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Stack, Text, ThemeIcon, Title } from "@mantine/core";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
const EmptyState = ({ icon, title, description, action }: EmptyStateProps) => {
|
||||
return (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack align="center" gap={4}>
|
||||
<Title order={3} c="dimmed" ta="center">
|
||||
{title}
|
||||
</Title>
|
||||
{description && (
|
||||
<Text size="sm" c="dimmed" ta="center" maw={280}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
||||
@@ -16,6 +16,7 @@ interface GlitchAvatarProps
|
||||
contain?: boolean;
|
||||
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<typeof setTimeout>;
|
||||
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)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
opacity: showGlitch ? 0 : 1,
|
||||
transition: "opacity 0.05s ease-in-out",
|
||||
transition: showGlitch
|
||||
? "opacity 0.05s ease-in"
|
||||
: "opacity 0.25s ease-out",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
@@ -141,7 +152,7 @@ const GlitchAvatar = ({
|
||||
left: "8px",
|
||||
opacity: showGlitch ? 1 : 0,
|
||||
visibility: showGlitch ? "visible" : "hidden",
|
||||
transition: "opacity 0.05s ease-in-out",
|
||||
transition: showGlitch ? "opacity 0.05s ease-in" : "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<UnstyledButton
|
||||
className={styles.listRow}
|
||||
w="100%"
|
||||
p="md"
|
||||
component={"button"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Divider, NavLink, Text } from "@mantine/core";
|
||||
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import styles from "./list-row.module.css";
|
||||
|
||||
interface ListLinkProps {
|
||||
label: string;
|
||||
@@ -15,6 +16,7 @@ const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
|
||||
return (
|
||||
<>
|
||||
<NavLink
|
||||
className={styles.listRow}
|
||||
disabled={disabled}
|
||||
w="100%"
|
||||
p="md"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.listRow {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.listRow:hover:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.listRow:active:not(:disabled) {
|
||||
background-color: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.listRow {
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
|
||||
.listRow:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Title,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { Box, Container, Title, useComputedColorScheme } from "@mantine/core";
|
||||
import { PropsWithChildren, useEffect, useRef } from "react";
|
||||
import { Drawer as VaulDrawer } from "vaul";
|
||||
import { CHROME_COLORS, setThemeColorMeta } from "@/lib/mantine/theme-colors";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
interface DrawerProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
onClose?: () => void;
|
||||
onExited?: () => void;
|
||||
}
|
||||
|
||||
const Drawer: React.FC<DrawerProps> = ({
|
||||
@@ -19,55 +17,25 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
children,
|
||||
opened,
|
||||
onChange,
|
||||
onExited,
|
||||
}) => {
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
const contentRef = useRef<HTMLDivElement>(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<HTMLElement>(".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<DrawerProps> = ({
|
||||
repositionInputs={false}
|
||||
open={opened}
|
||||
onOpenChange={onChange}
|
||||
onAnimationEnd={(open) => {
|
||||
if (!open) onExited?.();
|
||||
}}
|
||||
>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||
|
||||
@@ -5,13 +5,30 @@ interface ModalProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onChange?: (next: boolean) => void;
|
||||
onExited?: () => void;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
title,
|
||||
children,
|
||||
opened,
|
||||
onClose,
|
||||
onExited,
|
||||
}) => (
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>{title}</Title>}
|
||||
radius={20}
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
duration: 200,
|
||||
timingFunction: "ease-out",
|
||||
onExited,
|
||||
}}
|
||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||
closeButtonProps={{ "aria-label": "Close" }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { PropsWithChildren, Suspense, useCallback } from "react";
|
||||
import {
|
||||
PropsWithChildren,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import Drawer from "./drawer";
|
||||
import Modal from "./modal";
|
||||
@@ -10,13 +17,35 @@ interface SheetProps extends PropsWithChildren {
|
||||
onChange: (next: boolean) => void;
|
||||
}
|
||||
|
||||
const DRAWER_EXIT_MS = 500;
|
||||
const MODAL_EXIT_MS = 200;
|
||||
const EXIT_BUFFER_MS = 100;
|
||||
|
||||
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
const 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 (
|
||||
<SheetComponent
|
||||
@@ -24,6 +53,7 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
opened={opened}
|
||||
onChange={onChange}
|
||||
onClose={handleClose}
|
||||
onExited={handleExited}
|
||||
>
|
||||
<Suspense fallback={
|
||||
<Flex justify='center' align='center' w='100%' style={{ minHeight: '25vh' }}>
|
||||
|
||||
@@ -150,7 +150,11 @@ const SlidePanel = ({
|
||||
{panelConfig && (
|
||||
<>
|
||||
<Group justify="space-between" p="md" align="center" w="100%">
|
||||
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
onClick={closePanel}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
<Text fw={500}>{panelConfig.title}</Text>
|
||||
@@ -158,6 +162,7 @@ const SlidePanel = ({
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm"
|
||||
>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
|
||||
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
|
||||
import { SpotifyAuth } from '@/lib/spotify/auth';
|
||||
import { useAuth } from './auth-context';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
@@ -21,6 +21,62 @@ const defaultSpotifyState: SpotifyAuthState = {
|
||||
|
||||
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
|
||||
|
||||
const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) return true;
|
||||
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
const aKeys = Object.keys(a as Record<string, unknown>);
|
||||
const bKeys = Object.keys(b as Record<string, unknown>);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
return aKeys.every((key) =>
|
||||
deepEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
@@ -41,9 +97,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
|
||||
const [isResumeLoading, setIsResumeLoading] = useState(false);
|
||||
|
||||
const spotifyAuth = new SpotifyAuth(
|
||||
config.spotifyClientId,
|
||||
config.spotifyRedirectUri
|
||||
const spotifyAuth = useMemo(
|
||||
() => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri),
|
||||
[config.spotifyClientId, config.spotifyRedirectUri]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -126,45 +182,6 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
}, []);
|
||||
|
||||
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
|
||||
const response = await fetch(`/api/spotify/${endpoint}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Request failed';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.headers.get('content-length') === '0') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const play = useCallback(async (deviceId?: string) => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
@@ -351,11 +368,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const data = await makeSpotifyRequest('playback?type=state');
|
||||
const state = data.playbackState;
|
||||
|
||||
setPlaybackState(state);
|
||||
setCurrentTrack(state?.item || null);
|
||||
setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state));
|
||||
setCurrentTrack((prev) => {
|
||||
const next = state?.item || null;
|
||||
return deepEqual(prev, next) ? prev : next;
|
||||
});
|
||||
|
||||
if (state?.device) {
|
||||
setActiveDeviceState(state.device);
|
||||
setActiveDeviceState((prev) =>
|
||||
deepEqual(prev, state.device) ? prev : state.device
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh playback state:', error);
|
||||
@@ -365,8 +387,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
useEffect(() => {
|
||||
if (!authState.isAuthenticated) return;
|
||||
|
||||
const interval = setInterval(refreshPlaybackState, 5000);
|
||||
return () => clearInterval(interval);
|
||||
const poll = () => {
|
||||
if (document.hidden) return;
|
||||
refreshPlaybackState();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshPlaybackState();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(poll, 5000);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authState.isAuthenticated, refreshPlaybackState]);
|
||||
|
||||
const capturePlaybackState = useCallback(async () => {
|
||||
@@ -432,7 +470,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const contextValue: SpotifyContextType = {
|
||||
const contextValue: SpotifyContextType = useMemo(() => ({
|
||||
...authState,
|
||||
currentTrack,
|
||||
playbackState,
|
||||
@@ -461,7 +499,33 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
clearCapturedState,
|
||||
// Search
|
||||
searchTracks,
|
||||
};
|
||||
}), [
|
||||
authState,
|
||||
currentTrack,
|
||||
playbackState,
|
||||
devices,
|
||||
activeDevice,
|
||||
isLoading,
|
||||
error,
|
||||
capturedState,
|
||||
isCaptureLoading,
|
||||
isResumeLoading,
|
||||
login,
|
||||
logout,
|
||||
play,
|
||||
playTrack,
|
||||
pause,
|
||||
skipNext,
|
||||
skipPrevious,
|
||||
setVolume,
|
||||
getDevices,
|
||||
setActiveDevice,
|
||||
refreshPlaybackState,
|
||||
capturePlaybackState,
|
||||
resumePlaybackState,
|
||||
clearCapturedState,
|
||||
searchTracks,
|
||||
]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -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
|
||||
</Stack>
|
||||
|
||||
{result.items.length === 0 && (
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
No activities found
|
||||
</Text>
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<ThemeIcon size="xl" variant="light" radius="md">
|
||||
<PulseIcon size={32} />
|
||||
</ThemeIcon>
|
||||
<Title order={3} c="dimmed">
|
||||
No Activities Found
|
||||
</Title>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{result.totalPages > 1 && (
|
||||
@@ -339,7 +347,7 @@ export const ActivitiesTable = () => {
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("created") ? 600 : 400}
|
||||
c={sortBy.includes("created") ? "dark" : "dimmed"}
|
||||
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
Date
|
||||
</Text>
|
||||
@@ -355,7 +363,7 @@ export const ActivitiesTable = () => {
|
||||
<Text
|
||||
size="xs"
|
||||
fw={sortBy.includes("duration") ? 600 : 400}
|
||||
c={sortBy.includes("duration") ? "dark" : "dimmed"}
|
||||
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
|
||||
>
|
||||
Duration
|
||||
</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@ const activitySearchParamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const searchActivities = createServerFn()
|
||||
.inputValidator(activitySearchParamsSchema)
|
||||
.validator(activitySearchParamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<ActivityListResult>(async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ const ManageTournaments = () => {
|
||||
return (
|
||||
<List p="0">
|
||||
{tournaments.map((t) => (
|
||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||
<ListLink key={t.id} label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
|
||||
alt={badge.name}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
onError={() => setImageError(true)}
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
@@ -57,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);
|
||||
@@ -138,6 +161,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||
|
||||
return (
|
||||
<Box mb="lg">
|
||||
<style href="flxn-badge-tile" precedence="medium">
|
||||
{badgeTileCss}
|
||||
</style>
|
||||
<Box
|
||||
px="md"
|
||||
style={{
|
||||
@@ -167,9 +193,9 @@ const BadgeShowcase = ({ playerId }: BadgeShowcaseProps) => {
|
||||
<Popover key={display.badge.id} width={280} position="top" withArrow shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<Box
|
||||
className="flxn-badge-tile"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export const getPlayerBadges = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: playerId }) =>
|
||||
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
|
||||
@@ -28,7 +28,7 @@ export const getAllEarnedBadges = createServerFn()
|
||||
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
|
||||
|
||||
export const awardManualBadge = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
.validator(z.object({
|
||||
playerId: z.string(),
|
||||
badgeId: z.string(),
|
||||
}))
|
||||
|
||||
@@ -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<BracketViewProps> = ({ bracket, showControls, groupC
|
||||
|
||||
return <ScrollArea
|
||||
h={`calc(${height})`}
|
||||
className="bracket-container"
|
||||
className={styles["bracket-container"]}
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
|
||||
@@ -134,6 +134,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(match.tournament.id),
|
||||
});
|
||||
editSheet.close();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,9 +150,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
matchId: match.id,
|
||||
},
|
||||
});
|
||||
editSheet.close();
|
||||
},
|
||||
[match.id, editSheet]
|
||||
[end, match.id]
|
||||
);
|
||||
|
||||
const speak = useCallback((text: string): Promise<void> => {
|
||||
@@ -311,6 +311,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleSpeakerClick}
|
||||
aria-label="Announce matchup"
|
||||
>
|
||||
<SpeakerHighIcon size={12} />
|
||||
</ActionIcon>
|
||||
@@ -323,10 +324,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
<ActionIcon
|
||||
color="green"
|
||||
onClick={handleStart}
|
||||
loading={start.isPending}
|
||||
size="sm"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
aria-label="Start match"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
@@ -343,10 +346,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
onClick={editSheet.open}
|
||||
loading={end.isPending}
|
||||
size="sm"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
aria-label="Edit match score"
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
@@ -363,6 +368,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
match={match}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={editSheet.close}
|
||||
loading={end.isPending}
|
||||
/>
|
||||
</Sheet>
|
||||
</Flex>
|
||||
|
||||
@@ -10,12 +10,14 @@ interface MatchFormProps {
|
||||
ot_count: number;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const MatchForm: React.FC<MatchFormProps> = ({
|
||||
match,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}) => {
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
@@ -35,7 +37,6 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
||||
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<MatchFormProps> = ({
|
||||
</Flex>
|
||||
|
||||
<Stack mt="md">
|
||||
<Button type="submit">Update Match</Button>
|
||||
<Button variant="subtle" color="red" onClick={onCancel}>
|
||||
<Button type="submit" loading={loading}>
|
||||
Update Match
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.slotEnter {
|
||||
animation: slot-enter 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slot-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.slotEnter {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import 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<MatchSlotProps> = ({
|
||||
cups,
|
||||
isWinner,
|
||||
groupLabel
|
||||
}) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
style={{
|
||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
transition: 'background-color 200ms ease',
|
||||
}}
|
||||
>
|
||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||
<Flex p="6px 10px" w='100%' align="center">
|
||||
<Flex align="center" gap={4} flex={1}>
|
||||
{team ? (
|
||||
<>
|
||||
<Text
|
||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||
truncate
|
||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||
>
|
||||
{team.name}
|
||||
}) => {
|
||||
const teamId = team?.id;
|
||||
const previousTeamIdRef = useRef<string | undefined>(teamId);
|
||||
const [entranceKey, setEntranceKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const previousTeamId = previousTeamIdRef.current;
|
||||
previousTeamIdRef.current = teamId;
|
||||
if (teamId && previousTeamId !== teamId) {
|
||||
setEntranceKey((key) => key + 1);
|
||||
}
|
||||
}, [teamId]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="stretch"
|
||||
style={{
|
||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
transition: 'background-color 200ms ease',
|
||||
}}
|
||||
>
|
||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||
<Flex p="6px 10px" w='100%' align="center">
|
||||
<Flex
|
||||
key={entranceKey}
|
||||
className={entranceKey > 0 ? classes.slotEnter : undefined}
|
||||
align="center"
|
||||
gap={4}
|
||||
flex={1}
|
||||
>
|
||||
{team ? (
|
||||
<>
|
||||
<Text
|
||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||
truncate
|
||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
{isWinner && (
|
||||
<CrownIcon
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{
|
||||
color: 'gold',
|
||||
marginLeft: '2px',
|
||||
marginTop: '-1px',
|
||||
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : groupLabel ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{groupLabel}
|
||||
</Text>
|
||||
{isWinner && (
|
||||
<CrownIcon
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{
|
||||
color: 'gold',
|
||||
marginLeft: '2px',
|
||||
marginTop: '-1px',
|
||||
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : groupLabel ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{groupLabel}
|
||||
</Text>
|
||||
) : from ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
TBD
|
||||
</Text>
|
||||
)}
|
||||
) : from ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
TBD
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
{
|
||||
cups !== undefined ? (
|
||||
<AnimatedScore ta='center' w={15} fw={800} ml={4} size="xs" value={cups} />
|
||||
) : undefined
|
||||
}
|
||||
</Flex>
|
||||
{
|
||||
cups !== undefined ? (
|
||||
<Text ta='center' w={15} fw="800" ml={4} size="xs">{cups}</Text>
|
||||
) : undefined
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
const logger = new Logger("Bracket Generation");
|
||||
|
||||
export const previewBracket = createServerFn()
|
||||
.inputValidator(z.number())
|
||||
.validator(z.number())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teams }) =>
|
||||
toServerResult(async () => {
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||
<UnstyledButton
|
||||
aria-label='Go back'
|
||||
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
||||
onClick={() => router.history.back()}
|
||||
pos='absolute'
|
||||
left={left}
|
||||
top={top}
|
||||
>
|
||||
<ArrowLeftIcon weight='bold' size={20} />
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
|
||||
import { Title, AppShell, Flex } from "@mantine/core";
|
||||
import { HeaderConfig } from "../types/header-config";
|
||||
import 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 (
|
||||
<AppShell.Header
|
||||
id='app-header'
|
||||
display={collapsed ? 'none' : 'flex'}
|
||||
style={{ alignItems: 'center', justifyContent: 'center' }}
|
||||
withBorder={false}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: `1px solid ${elevated ? 'var(--app-shell-border-color, var(--mantine-color-default-border))' : 'transparent'}`,
|
||||
transition: 'border-color 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{ withBackButton && <BackButton /> }
|
||||
<Flex justify='center' px='md' mt={8}>
|
||||
<Title order={1}>{title?.toLocaleUpperCase()}</Title>
|
||||
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
||||
{title?.toLocaleUpperCase()}
|
||||
</Title>
|
||||
</Flex>
|
||||
</AppShell.Header>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import Header from './header';
|
||||
import Navbar from './navbar';
|
||||
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<PropsWithChildren> = ({ children }) => {
|
||||
const { header } = useRouterConfig();
|
||||
const { header, withPadding, fullWidth } = useRouterConfig();
|
||||
const viewport = useVisualViewportSize();
|
||||
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
|
||||
const { withPadding, fullWidth } = useRouterConfig();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -37,17 +36,17 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
// top: viewport.top
|
||||
}}
|
||||
>
|
||||
<Header {...header} />
|
||||
<Header {...header} elevated={scrollPosition.y > 2} />
|
||||
<AppShell.Main
|
||||
pos='relative'
|
||||
h='100%'
|
||||
mah='100%'
|
||||
pb={{ base: 65, sm: 0 }}
|
||||
pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }}
|
||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||
maw='100dvw'
|
||||
style={{ transition: 'none', overflow: 'hidden' }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
|
||||
<Pullable>
|
||||
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
||||
{children}
|
||||
</Page>
|
||||
|
||||
@@ -19,14 +19,13 @@ export const NavLink = ({
|
||||
include,
|
||||
exclude,
|
||||
}: NavLinkProps) => {
|
||||
const router = useRouterState();
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
||||
const isActive = useMemo(
|
||||
() =>
|
||||
(!exclude?.some((e) => router.location.pathname.includes(e)) &&
|
||||
(router.location.pathname === href ||
|
||||
(router.location.pathname.includes(href) && href !== "/"))) ||
|
||||
include?.includes(router.location.pathname),
|
||||
[router.location.pathname, href]
|
||||
(!exclude?.some((e) => pathname.includes(e)) &&
|
||||
(pathname === href || (pathname.includes(href) && href !== "/"))) ||
|
||||
include?.includes(pathname),
|
||||
[pathname, href]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -35,6 +34,8 @@ export const NavLink = ({
|
||||
to={href}
|
||||
className={styles.navLinkBox}
|
||||
p={{ base: 0, sm: 8 }}
|
||||
aria-label={label}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
|
||||
@@ -2,5 +2,26 @@
|
||||
text-decoration: none;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
color: unset;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.navLinkBox svg,
|
||||
.navLinkBox p {
|
||||
transition: color 150ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.navLinkBox {
|
||||
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.navLinkBox:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
|
||||
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
|
||||
import { NavLink } from "./nav-link";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
@@ -9,18 +9,16 @@ import { useIsPWA } from "@/hooks/use-is-pwa";
|
||||
const Navbar = () => {
|
||||
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 (
|
||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={isPWA ? '1rem' : '0'} style={{ zIndex: 10 }}>
|
||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
|
||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||
{links.map((link) => (
|
||||
<NavLink key={link.href} {...link} />
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
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;
|
||||
|
||||
interface PullableProps extends PropsWithChildren {
|
||||
scrollPosition: { x: number, y: number };
|
||||
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<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
|
||||
const Pullable: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const height = useAppShellHeight();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [scrolling, setScrolling] = useState(false);
|
||||
@@ -25,20 +17,29 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
const location = useLocation();
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
|
||||
const handleScrollPositionChange = useCallback((position: { x: number, y: number }) => {
|
||||
const next = position.y < 0 ? Math.abs(position.y) : 0;
|
||||
setPullDistance((prev) => (prev === next ? prev : next));
|
||||
}, []);
|
||||
|
||||
const scrollY = scrolling ? pullDistance : 0;
|
||||
|
||||
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 +86,6 @@ const Pullable: React.FC<PullableProps> = ({ 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 +96,29 @@ const Pullable: React.FC<PullableProps> = ({ 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;
|
||||
};
|
||||
@@ -143,11 +139,11 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
viewport.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
onScrollPositionChange({ x: 0, y: 0 });
|
||||
setPullDistance(0);
|
||||
}, 10);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [location.pathname, onScrollPositionChange]);
|
||||
}, [location.pathname]);
|
||||
|
||||
|
||||
return (
|
||||
@@ -175,7 +171,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
id='scroll-wrapper'
|
||||
onScrollPositionChange={onScrollPositionChange}
|
||||
onScrollPositionChange={handleScrollPositionChange}
|
||||
type='never' mah='100%' h='100%'
|
||||
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
|
||||
styles={{
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
::view-transition-old(root) {
|
||||
animation: flxn-route-fade-out 120ms ease-out both;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: flxn-route-fade-in 200ms ease-out both;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(*),
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flxn-route-fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flxn-route-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box } from "@mantine/core"
|
||||
import { UnstyledButton } from "@mantine/core"
|
||||
import { GearIcon } from "@phosphor-icons/react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { memo } from "react";
|
||||
@@ -13,16 +13,17 @@ const SettingsButton = ({ to }: SettingButtonProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ cursor: 'pointer', zIndex: 1000 }}
|
||||
<UnstyledButton
|
||||
aria-label='Settings'
|
||||
style={{ cursor: 'pointer', zIndex: 1000, display: 'flex' }}
|
||||
onClick={() => navigate({ to })}
|
||||
pos='absolute'
|
||||
right={20}
|
||||
top={6}
|
||||
>
|
||||
<GearIcon weight='bold' size={20} />
|
||||
</Box>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsButton, (prev, next) => prev.to !== next.to);
|
||||
export default memo(SettingsButton, (prev, next) => prev.to === next.to);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMatches } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { HeaderConfig } from "../types/header-config";
|
||||
|
||||
export const defaultHeaderConfig: HeaderConfig = {
|
||||
@@ -10,32 +11,34 @@ export const defaultHeaderConfig: HeaderConfig = {
|
||||
const useRouterConfig = () => {
|
||||
const matches = useMatches();
|
||||
|
||||
const matchesWithHeader = matches.filter((match) =>
|
||||
match?.loaderData && 'header' in match.loaderData
|
||||
);
|
||||
return useMemo(() => {
|
||||
const matchesWithHeader = matches.filter((match) =>
|
||||
match?.loaderData && 'header' in match.loaderData
|
||||
);
|
||||
|
||||
const headerConfig = matchesWithHeader.reduce((acc, match) => {
|
||||
const loaderData = match?.loaderData;
|
||||
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
|
||||
const header = loaderData.header;
|
||||
if (header && typeof header === 'object') {
|
||||
return {
|
||||
...acc,
|
||||
...header,
|
||||
const headerConfig = matchesWithHeader.reduce((acc, match) => {
|
||||
const loaderData = match?.loaderData;
|
||||
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
|
||||
const header = loaderData.header;
|
||||
if (header && typeof header === 'object') {
|
||||
return {
|
||||
...acc,
|
||||
...header,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, defaultHeaderConfig);
|
||||
return acc;
|
||||
}, defaultHeaderConfig);
|
||||
|
||||
const current = matches[matches.length - 1]?.loaderData;
|
||||
const current = matches[matches.length - 1]?.loaderData;
|
||||
|
||||
return {
|
||||
header: headerConfig,
|
||||
refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [],
|
||||
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true,
|
||||
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
|
||||
};
|
||||
return {
|
||||
header: headerConfig,
|
||||
refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [],
|
||||
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true,
|
||||
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
|
||||
};
|
||||
}, [matches]);
|
||||
}
|
||||
|
||||
export default useRouterConfig;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const eventListerOptions = {
|
||||
passive: true,
|
||||
@@ -11,25 +11,49 @@ const useVisualViewportSize = () => {
|
||||
height: windowExists ? window.visualViewport?.height || 0 : 0,
|
||||
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
|
||||
});
|
||||
|
||||
const setSize = useCallback(() => {
|
||||
if (!windowExists) return;
|
||||
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
|
||||
}, []);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!windowExists) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const setSize = () => {
|
||||
setWindowSize((prev) => {
|
||||
const next = {
|
||||
width: window.visualViewport?.width || 0,
|
||||
height: window.visualViewport?.height || 0,
|
||||
top: window.visualViewport?.offsetTop || 0,
|
||||
};
|
||||
if (
|
||||
prev.width === next.width &&
|
||||
prev.height === next.height &&
|
||||
prev.top === next.top
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleSetSize = () => {
|
||||
if (rafRef.current !== null) return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
setSize();
|
||||
});
|
||||
};
|
||||
|
||||
setSize();
|
||||
|
||||
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
|
||||
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
|
||||
window.visualViewport?.addEventListener('resize', scheduleSetSize, eventListerOptions);
|
||||
|
||||
return () => {
|
||||
window.visualViewport?.removeEventListener('resize', setSize);
|
||||
window.visualViewport?.removeEventListener('scroll', setSize);
|
||||
}
|
||||
}, [setSize]);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleSetSize);
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
@@ -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<PropsWithChildren> = ({ children }) => {
|
||||
style={{ transition: 'padding-top 0.1s ease' }}
|
||||
>
|
||||
<Paper
|
||||
shadow='none'
|
||||
shadow='md'
|
||||
withBorder
|
||||
p='md'
|
||||
w='100%'
|
||||
maw='375px'
|
||||
@@ -37,6 +38,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
<GlitchAvatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
frame
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
@@ -54,7 +56,12 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
>
|
||||
<TrophyIcon size={32} />
|
||||
</GlitchAvatar>
|
||||
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||
<Stack align='center' gap={2}>
|
||||
<Title order={1} ta='center'>Welcome to FLXN</Title>
|
||||
<Text size='sm' c='dimmed' fs='italic' ta='center'>
|
||||
Amicus meus madidus
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{children}
|
||||
</Paper>
|
||||
|
||||
@@ -16,7 +16,7 @@ const LoginFlow = () => {
|
||||
}
|
||||
|
||||
return <Center>
|
||||
<Loader color="blue" size="xl" type="dots" />
|
||||
<Loader size="xl" type="dots" />
|
||||
</Center>;
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const PlayerPrompt = () => {
|
||||
|
||||
return <>
|
||||
<UnstyledButton
|
||||
aria-label="Go back"
|
||||
onClick={() => setStage(undefined)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.value {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: score-pulse 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes score-pulse {
|
||||
from {
|
||||
transform: scale(1.3);
|
||||
color: var(--mantine-color-red-filled);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Text, TextProps } from "@mantine/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import classes from "./animated-score.module.css";
|
||||
|
||||
interface AnimatedScoreProps extends TextProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
const AnimatedScore = ({ value, ...textProps }: AnimatedScoreProps) => {
|
||||
const previousValueRef = useRef<number>(value);
|
||||
const [pulseKey, setPulseKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousValueRef.current !== value) {
|
||||
previousValueRef.current = value;
|
||||
setPulseKey((key) => key + 1);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Text {...textProps}>
|
||||
<span
|
||||
key={pulseKey}
|
||||
className={
|
||||
pulseKey > 0 ? `${classes.value} ${classes.pulse}` : classes.value
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedScore;
|
||||
@@ -4,10 +4,11 @@ import { useNavigate } from "@tanstack/react-router";
|
||||
import { Match } from "../types";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||
import { Suspense } from "react";
|
||||
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) => {
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Text
|
||||
<AnimatedScore
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
display={match.status === "ended" ? undefined : "none"}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
value={match.home_cups}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
@@ -192,14 +192,13 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Text
|
||||
<AnimatedScore
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={"dimmed"}
|
||||
display={match.status === "ended" ? undefined : "none"}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
value={match.away_cups}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -237,4 +236,4 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchCard;
|
||||
export default memo(MatchCard);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { Match } from "../types";
|
||||
import MatchCard from "./match-card";
|
||||
|
||||
@@ -8,9 +9,15 @@ interface MatchListProps {
|
||||
}
|
||||
|
||||
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
|
||||
const filteredMatches = matches?.filter(match =>
|
||||
match.home && match.away && !match.bye && match.status != "tbd"
|
||||
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
|
||||
const filteredMatches = useMemo(
|
||||
() =>
|
||||
[...(matches ?? [])]
|
||||
.filter(match =>
|
||||
match.home && match.away && !match.bye && match.status != "tbd"
|
||||
)
|
||||
.sort((a, b) => a.start_time < b.start_time ? 1 : -1),
|
||||
[matches]
|
||||
);
|
||||
|
||||
if (!filteredMatches.length) {
|
||||
return undefined;
|
||||
|
||||
@@ -17,7 +17,7 @@ const orderedTeamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.inputValidator(orderedTeamsSchema)
|
||||
.validator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -138,7 +138,7 @@ export const generateTournamentBracket = createServerFn()
|
||||
);
|
||||
|
||||
export const startMatch = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -165,7 +165,7 @@ export const startMatch = createServerFn()
|
||||
);
|
||||
|
||||
export const populateKnockoutBracket = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
.validator(z.object({
|
||||
tournamentId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
@@ -459,7 +459,7 @@ const endMatchSchema = z.object({
|
||||
ot_count: z.number(),
|
||||
});
|
||||
export const endMatch = createServerFn()
|
||||
.inputValidator(endMatchSchema)
|
||||
.validator(endMatchSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -546,7 +546,7 @@ const toggleReactionSchema = z.object({
|
||||
});
|
||||
|
||||
export const toggleMatchReaction = createServerFn()
|
||||
.inputValidator(toggleReactionSchema)
|
||||
.validator(toggleReactionSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -606,7 +606,7 @@ export interface Reaction {
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
export const getMatchReactions = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: matchId, context }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -647,7 +647,7 @@ const matchesBetweenPlayersSchema = z.object({
|
||||
});
|
||||
|
||||
export const getMatchesBetweenPlayers = createServerFn()
|
||||
.inputValidator(matchesBetweenPlayersSchema)
|
||||
.validator(matchesBetweenPlayersSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { player1Id, player2Id } }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -663,7 +663,7 @@ const matchesBetweenTeamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const getMatchesBetweenTeams = createServerFn()
|
||||
.inputValidator(matchesBetweenTeamsSchema)
|
||||
.validator(matchesBetweenTeamsSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { team1Id, team2Id } }) =>
|
||||
toServerResult(async () => {
|
||||
|
||||
@@ -330,7 +330,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
||||
onClick={() => handleSort("mmr")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "dark" : "dimmed"}>
|
||||
<Text size="xs" fw={sortConfig.key === "mmr" ? 600 : 400} c={sortConfig.key === "mmr" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||
MMR
|
||||
</Text>
|
||||
{getSortIcon("mmr")}
|
||||
@@ -340,7 +340,7 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
||||
onClick={() => handleSort("wins")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "dark" : "dimmed"}>
|
||||
<Text size="xs" fw={sortConfig.key === "wins" ? 600 : 400} c={sortConfig.key === "wins" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||
Wins
|
||||
</Text>
|
||||
{getSortIcon("wins")}
|
||||
@@ -350,14 +350,14 @@ const PlayerStatsTable = ({ viewType = 'all' }: PlayerStatsTableProps) => {
|
||||
onClick={() => handleSort("matches")}
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "dark" : "dimmed"}>
|
||||
<Text size="xs" fw={sortConfig.key === "matches" ? 600 : 400} c={sortConfig.key === "matches" ? "var(--mantine-color-text)" : "dimmed"}>
|
||||
Matches
|
||||
</Text>
|
||||
{getSortIcon("matches")}
|
||||
</UnstyledButton>
|
||||
<Popover position="bottom-end" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
<ActionIcon variant="subtle" size="sm" aria-label="Stat abbreviations and MMR info">
|
||||
<InfoIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
|
||||
@@ -30,7 +30,7 @@ const StatsWithFilter = ({ id }: { id: string }) => {
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 150ms' }}>
|
||||
<Suspense key={deferredViewType} fallback={<StatsSkeleton />}>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<StatsContent id={id} viewType={deferredViewType} />
|
||||
</Suspense>
|
||||
</Box>
|
||||
@@ -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 <StatsOverview statsData={stats} isLoading={statsLoading} />;
|
||||
const { data: stats } = usePlayerStats(id, viewType);
|
||||
return <StatsOverview statsData={stats} />;
|
||||
};
|
||||
|
||||
const Profile = ({ id }: ProfileProps) => {
|
||||
|
||||
@@ -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 = () => (
|
||||
<Flex h="30vh" w="100%" align="center" justify="center">
|
||||
<Loader />
|
||||
</Flex>
|
||||
)
|
||||
const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
|
||||
<Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
|
||||
<Stack gap="sm">
|
||||
<Skeleton height={12} width="55%" radius="sm" />
|
||||
{[0, 1].map((row) => (
|
||||
<Group key={row} justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<Skeleton height={40} width={40} radius="sm" />
|
||||
<Skeleton height={14} width="40%" radius="sm" />
|
||||
</Group>
|
||||
<Skeleton height={12} width={64} radius="sm" />
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const MatchListSkeleton = ({ count = 4 }: { count?: number }) => (
|
||||
<Stack p="md" gap="sm">
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<MatchCardSkeleton
|
||||
key={`match-skeleton-${index}`}
|
||||
opacity={Math.max(1 - index * 0.15, 0.4)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const OverviewSkeleton = () => (
|
||||
<>
|
||||
<Stack px="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Skeleton height={16} width={70} radius="sm" />
|
||||
<Skeleton height={14} width={100} radius="sm" />
|
||||
</Group>
|
||||
<BadgeShowcaseSkeleton />
|
||||
</Stack>
|
||||
<Divider my="md" />
|
||||
<Stack>
|
||||
<Group gap="xs" px="md" justify="space-between" align="center">
|
||||
<Skeleton height={16} width={80} radius="sm" />
|
||||
<Group gap="xs">
|
||||
<Skeleton height={22} width={40} radius="xl" />
|
||||
<Skeleton height={22} width={64} radius="xl" />
|
||||
<Skeleton height={22} width={62} radius="xl" />
|
||||
</Group>
|
||||
</Group>
|
||||
<StatsSkeleton />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
const ProfileSkeleton = () => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <StatsSkeleton />,
|
||||
content: <OverviewSkeleton />,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <SkeletonLoader />,
|
||||
content: <MatchListSkeleton />,
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <SkeletonLoader />,
|
||||
content: <TeamList teams={[]} loading />,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import { createServerFn } from "@tanstack/react-start";
|
||||
import { isRedirect } from "@tanstack/react-router";
|
||||
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
import { logger } from ".";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
const logger = new Logger('Players');
|
||||
|
||||
export const fetchMe = createServerFn()
|
||||
.handler(async () =>
|
||||
toServerResult(async () => {
|
||||
@@ -18,10 +19,8 @@ export const fetchMe = createServerFn()
|
||||
try {
|
||||
const context = await getSessionContext(request);
|
||||
|
||||
await pbAdmin.authPromise;
|
||||
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
return {
|
||||
user: result || undefined,
|
||||
user: context.player || undefined,
|
||||
roles: context.roles,
|
||||
metadata: context.metadata,
|
||||
phone: context.phone
|
||||
@@ -35,20 +34,25 @@ export const fetchMe = createServerFn()
|
||||
);
|
||||
|
||||
export const getPlayer = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||
toServerResult<Player>(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getPlayer(data);
|
||||
})
|
||||
);
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.inputValidator(playerUpdateSchema)
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
if (!existing) return;
|
||||
|
||||
@@ -69,13 +73,15 @@ export const updatePlayer = createServerFn()
|
||||
);
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.inputValidator(playerInputSchema)
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
if (existing) return;
|
||||
|
||||
@@ -94,13 +100,15 @@ export const createPlayer = createServerFn()
|
||||
);
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
|
||||
const p = await pbAdmin.getPlayer(data);
|
||||
|
||||
await pbAdmin.updatePlayer(data, {
|
||||
@@ -122,48 +130,69 @@ export const associatePlayer = createServerFn()
|
||||
export const listPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.listPlayers)
|
||||
toServerResult(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.listPlayers();
|
||||
})
|
||||
);
|
||||
|
||||
export const getUnassociatedPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.getUnassociatedPlayers)
|
||||
toServerResult(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getUnassociatedPlayers();
|
||||
})
|
||||
);
|
||||
|
||||
export const getPlayerStats = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
.validator(z.object({
|
||||
playerId: z.string(),
|
||||
viewType: z.enum(['all', 'mainline', 'regional']).optional()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data.playerId, data.viewType))
|
||||
toServerResult<PlayerStats>(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getPlayerStats(data.playerId, data.viewType);
|
||||
})
|
||||
);
|
||||
|
||||
export const getAllPlayerStats = createServerFn()
|
||||
.inputValidator(z.enum(['all', 'mainline', 'regional']).optional())
|
||||
.validator(z.enum(['all', 'mainline', 'regional']).optional())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<PlayerStats[]>(async () => await pbAdmin.getAllPlayerStats(data))
|
||||
toServerResult<PlayerStats[]>(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getAllPlayerStats(data);
|
||||
})
|
||||
);
|
||||
|
||||
export const getPlayerMatches = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||
toServerResult<Match[]>(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getPlayerMatches(data);
|
||||
})
|
||||
);
|
||||
|
||||
export const getUnenrolledPlayers = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||
toServerResult(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getUnenrolledPlayers(tournamentId);
|
||||
})
|
||||
);
|
||||
|
||||
export const getPlayersActivity = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult<Player[]>(async () => await pbAdmin.getPlayersActivity())
|
||||
toServerResult<Player[]>(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.getPlayersActivity();
|
||||
})
|
||||
);
|
||||
|
||||