significant refactor

This commit is contained in:
2025-08-30 01:42:23 -05:00
parent 7136f646a3
commit 052f53444e
106 changed files with 1994 additions and 1701 deletions

View File

@@ -1,10 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/lib/drizzle/schema",
out: "./src/lib/drizzle/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.VITE_DATABASE_URL ?? "",
},
});

View File

@@ -6,10 +6,7 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "vite start",
"db:generate": "npx drizzle-kit generate --config drizzle.config.ts",
"db:push": "npx drizzle-kit push",
"db:studio": "npx drizzle-kit studio"
"start": "vite start"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -28,8 +25,6 @@
"@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "^1.130.15",
"@types/ioredis": "^4.28.10",
"drizzle-orm": "^0.44.4",
"drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
@@ -57,7 +52,6 @@
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"dotenv-cli": "^10.0.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",

View File

@@ -25,7 +25,6 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
@@ -105,11 +104,6 @@ const AuthedAdminTournamentsIdRoute =
path: '/tournaments/$id',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTestServerRoute = ApiTestServerRouteImport.update({
id: '/api/test',
path: '/api/test',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo',
@@ -233,14 +227,12 @@ export interface RootRouteChildren {
LogoutRoute: typeof LogoutRoute
}
export interface FileServerRoutesByFullPath {
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRoutesByTo {
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
@@ -248,7 +240,6 @@ export interface FileServerRoutesByTo {
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
@@ -257,21 +248,18 @@ export interface FileServerRoutesById {
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths:
| '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesByTo: FileServerRoutesByTo
to:
| '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
id:
| '__root__'
| '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
@@ -279,7 +267,6 @@ export interface FileServerRouteTypes {
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
ApiTestServerRoute: typeof ApiTestServerRoute
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
@@ -390,13 +377,6 @@ declare module '@tanstack/react-router' {
}
declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath {
'/api/test': {
id: '/api/test'
path: '/api/test'
fullPath: '/api/test'
preLoaderRoute: typeof ApiTestServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
@@ -478,7 +458,6 @@ export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiTestServerRoute: ApiTestServerRoute,
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,

View File

@@ -1,9 +1,9 @@
import { QueryClient } from '@tanstack/react-query'
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
import { routeTree } from './routeTree.gen'
import { DefaultCatchBoundary } from '../components/DefaultCatchBoundary'
import { defaultHeaderConfig } from '@/features/core/hooks/use-router-config'
import { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function createRouter() {
const queryClient = new QueryClient({
@@ -12,27 +12,33 @@ export function createRouter() {
staleTime: 60 * 1000, // 60 seconds
gcTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
refetchOnReconnect: 'always',
refetchOnReconnect: "always",
retry: 3,
},
},
})
});
return routerWithQueryClient(
createTanStackRouter({
routeTree,
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: [], withPadding: true },
defaultPreload: 'intent',
context: {
queryClient,
auth: undefined!,
header: defaultHeaderConfig,
refresh: [],
withPadding: true,
},
defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: true
defaultViewTransition: true,
}),
queryClient,
)
queryClient
);
}
declare module '@tanstack/react-router' {
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createRouter>
router: ReturnType<typeof createRouter>;
}
}

View File

@@ -1,59 +1,63 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/carousel/styles.css';
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css";
import {
HeadContent,
Navigate,
Outlet,
Scripts,
createRootRouteWithContext
} from '@tanstack/react-router'
import * as React from 'react'
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
import { type QueryClient } from '@tanstack/react-query'
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client'
import { AuthContextType, authQueryConfig } from '@/contexts/auth-context'
import Providers from '@/features/core/components/providers'
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
import { HeaderConfig } from '@/features/core/types/header-config';
createRootRouteWithContext,
} from "@tanstack/react-router";
import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createRootRouteWithContext<{
queryClient: QueryClient,
auth: AuthContextType,
header: HeaderConfig,
refresh: string[]
withPadding: boolean
queryClient: QueryClient;
auth: AuthContextType;
header: HeaderConfig;
refresh: string[];
withPadding: boolean;
}>()({
head: () => ({
meta: [
{
charSet: 'utf-8'
charSet: "utf-8",
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content',
}
name: "viewport",
content:
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
},
],
links: [
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/favicon-16x16.png',
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{ rel: 'manifest', href: '/site.webmanifest' },
{ rel: 'icon', href: '/favicon.ico' },
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
],
}),
errorComponent: (props) => {
@@ -61,25 +65,24 @@ export const Route = createRootRouteWithContext<{
<RootDocument>
<DefaultCatchBoundary {...props} />
</RootDocument>
)
);
},
component: RootComponent,
notFoundComponent: () => <Navigate to="/" />,
beforeLoad: async ({ context }) => {
// I don't really like this. I wish there was some way before the router is rendered to useAuth() and pass context there.
// See: https://github.com/TanStack/router/discussions/3531
const auth = await context.queryClient.ensureQueryData(authQueryConfig)
return {
auth
};
}
})
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
},
pendingComponent: () => <p>Loading...</p>,
});
function RootComponent() {
React.useEffect(() => {
ensureSuperTokensFrontend()
}, [])
ensureSuperTokensFrontend();
}, []);
return (
<RootDocument>
@@ -87,25 +90,38 @@ function RootComponent() {
<Outlet />
</Providers>
</RootDocument>
)
);
}
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<html
{...mantineHtmlProps}
style={{
overflowX: "hidden",
overflowY: "hidden",
position: "fixed",
width: "100%",
}}
>
<head>
<HeadContent />
<ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" />
<script defer src="https://analytics.yohler.net/script.js" data-website-id="0280f304-17a6-400c-8021-4d83a62d0c1b"></script>
</head>
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<div className='app'>
{children}
</div>
<body
style={{
overflowX: "hidden",
overflowY: "hidden",
position: "fixed",
width: "100%",
}}
>
<div className="app">{children}</div>
<Scripts />
<ReactQueryDevtools />
</body>
</html>
)
);
}

View File

@@ -1,18 +1,19 @@
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
import Layout from "@/features/core/components/layout";
import { useServerEvents } from "@/hooks/use-server-events";
import { Loader } from "@mantine/core";
export const Route = createFileRoute('/_authed')({
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
if (!context.auth?.user) {
throw redirect({ to: '/login' })
throw redirect({ to: "/login" });
}
return {
auth: {
...context.auth,
user: context.auth.user
}
user: context.auth.user,
},
};
},
component: () => {
@@ -21,6 +22,11 @@ export const Route = createFileRoute('/_authed')({
<Layout>
<Outlet />
</Layout>
)
}
})
);
},
pendingComponent: () => (
<Layout>
<Loader size="xl" />
</Layout>
),
});

View File

@@ -1,18 +1,18 @@
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/_authed/admin')({
export const Route = createFileRoute("/_authed/admin")({
component: Outlet,
beforeLoad: ({ context }) => {
if (!context.auth?.roles?.includes('Admin')) {
throw redirect({ to: '/' })
if (!context.auth?.roles?.includes("Admin")) {
throw redirect({ to: "/" });
}
return {
header: {
...context.header,
title: 'Admin',
withBackButton: true
title: "Admin",
withBackButton: true,
},
};
}
})
},
});

View File

@@ -1,13 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"
import { createFileRoute } from "@tanstack/react-router";
import { AdminPage } from "@/features/admin";
export const Route = createFileRoute("/_authed/admin/")({
loader: () => ({
header: {
withBackButton: true,
title: "Admin"
title: "Admin",
},
withPadding: false
withPadding: false,
}),
component: () => <AdminPage />
})
component: () => <AdminPage />,
});

View File

@@ -1,17 +1,17 @@
import { PreviewBracket } from '@/features/bracket/components/preview'
import { createFileRoute } from '@tanstack/react-router'
import { PreviewBracket } from "@/features/bracket/components/preview";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute('/_authed/admin/preview')({
export const Route = createFileRoute("/_authed/admin/preview")({
component: RouteComponent,
loader: () => ({
header: {
withBackButton: true,
title: "Bracket Preview"
title: "Bracket Preview",
},
withPadding: false
})
})
withPadding: false,
}),
});
function RouteComponent() {
return <PreviewBracket />
return <PreviewBracket />;
}

View File

@@ -1,27 +1,31 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'
import ManageTournament from '@/features/tournaments/components/manage-tournament'
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";
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await queryClient.ensureQueryData(tournamentQueries.details(params.id))
if (!tournament) throw redirect({ to: '/admin/tournaments' });
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament
}
tournament,
};
},
loader: ({ context }) => ({
header: {
withBackButton: true,
title: `Manage ${context.tournament.name}`,
},
withPadding: false
withPadding: false,
}),
component: RouteComponent,
})
});
function RouteComponent() {
const { id } = Route.useParams()
return <ManageTournament tournamentId={id} />
const { id } = Route.useParams();
return <ManageTournament tournamentId={id} />;
}

View File

@@ -1,12 +1,12 @@
import Page from "@/components/page";
import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/tournaments/")({
beforeLoad: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.list());
await prefetchServerQuery(queryClient, tournamentQueries.list());
},
loader: () => ({
header: {
@@ -14,11 +14,11 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
title: "Manage Tournaments",
},
refresh: tournamentQueries.list().queryKey,
withPadding: false
withPadding: false,
}),
component: RouteComponent,
});
function RouteComponent() {
return <ManageTournaments />
return <ManageTournaments />;
}

View File

@@ -1,15 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import Page from "@/components/page";
import { TrophyIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import { tournamentQueries } from "@/features/tournaments/queries";
import { Box, Divider, Text } from "@mantine/core";
export const Route = createFileRoute("/_authed/")({
component: Home,
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(tournamentQueries.list());
},
loader: () => ({
withPadding: false
})

View File

@@ -1,7 +1,7 @@
import Page from "@/components/page";
import Profile from "@/features/players/components/profile";
import { playerQueries } from "@/features/players/queries";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
@@ -12,22 +12,22 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
if (!player) throw redirect({ to: '/' });
return {
player
}
await prefetchServerQuery(
queryClient,
playerQueries.details(params.playerId)
);
},
loader: ({ params, context }) => ({
header: {
collapsed: true,
withBackButton: true,
settingsLink: context?.auth.user.id === params.playerId ? 'settings' : undefined
settingsLink:
context?.auth.user.id === params.playerId ? "/settings" : undefined,
},
refresh: [playerQueries.details(params.playerId).queryKey]
refresh: [playerQueries.details(params.playerId).queryKey],
}),
component: () => {
const { player } = Route.useRouteContext();
return <Profile player={player} />
const { playerId } = Route.useParams();
return <Profile id={playerId} />;
},
})
});

View File

@@ -1,34 +1,38 @@
import { createFileRoute } from "@tanstack/react-router"
import { Box, Title, Stack } from "@mantine/core"
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker"
import AccentColorPicker from "@/features/settings/components/accent-color-picker"
import { SignOutIcon } from "@phosphor-icons/react"
import ListLink from "@/components/list-link"
import { createFileRoute } from "@tanstack/react-router";
import { Box, Title, Stack } from "@mantine/core";
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
import { SignOutIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
export const Route = createFileRoute("/_authed/settings")({
loader: () => ({
header: {
title: 'Settings',
title: "Settings",
withBackButton: true,
},
withPadding: false
withPadding: false,
}),
component: RouteComponent,
})
});
function RouteComponent() {
return <>
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
return (
<>
<Box
px="md"
py="sm"
style={{
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Title order={3}>Appearance</Title>
<Stack>
<AccentColorPicker />
<ColorSchemePicker />
</Stack>
</Box>
<ListLink
label='Sign Out'
to='/logout'
Icon={SignOutIcon}
/>
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
</>
);
}

View File

@@ -1,6 +1,7 @@
import Page from "@/components/page";
import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
@@ -12,19 +13,17 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
if (!team) throw redirect({ to: '/' });
return { team }
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
},
loader: ({ params }) => ({
header: {
collapsed: true,
withBackButton: true
withBackButton: true,
},
refresh: [teamQueries.details(params.teamId).queryKey]
refresh: [teamQueries.details(params.teamId).queryKey],
}),
component: () => {
const { team } = Route.useRouteContext();
return <TeamProfile team={team} />
const { teamId } = Route.useParams();
return <TeamProfile id={teamId} />;
},
})
});

View File

@@ -1,16 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries';
import Page from '@/components/page'
import { useQuery } from '@tanstack/react-query';
import { Box, Group, Title } from '@mantine/core';
import { useSheet } from '@/hooks/use-sheet';
import Sheet from '@/components/sheet/sheet';
import { Tournament } from '@/features/tournaments/types';
import TeamList from '@/features/teams/components/team-list';
import Button from '@/components/button';
import Avatar from '@/components/avatar';
import Profile from '@/features/tournaments/components/profile';
import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
const searchSchema = z.object({
tab: z.string().optional(),
@@ -20,7 +12,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
},
loader: ({ params, context }) => ({
header: {
@@ -35,6 +27,6 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
})
function RouteComponent() {
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
return <Profile tournament={tournament!} />
const tournamentId = Route.useParams().tournamentId;
return <Profile id={tournamentId} />
}

View File

@@ -2,19 +2,19 @@ import Page from '@/components/page'
import { Stack } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
import { tournamentQueries } from '@/features/tournaments/queries'
import { useQuery } from '@tanstack/react-query'
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
import { useAuth } from '@/contexts/auth-context'
import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/sheet'
import TournamentForm from '@/features/tournaments/components/tournament-form'
import { PlusIcon } from '@phosphor-icons/react'
import Button from '@/components/button'
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.list())
await prefetchServerQuery(queryClient, tournamentQueries.list())
},
loader: () => ({
header: {
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
})
function RouteComponent() {
const { data: tournaments } = useQuery(tournamentQueries.list());
const { data: tournaments } = useTournaments();
const { roles } = useAuth();
const sheet = useSheet();

View File

@@ -1,9 +0,0 @@
import { createServerFileRoute } from '@tanstack/react-start/server';
import { superTokensRequestMiddleware } from '@/utils/supertokens';
// Simple test route for testing the auth middleware
export const ServerRoute = createServerFileRoute('/api/test').middleware([superTokensRequestMiddleware]).methods({
GET: () => {
return new Response('Hello from the authenticated API!')
},
})

View File

@@ -4,17 +4,16 @@ import { redirect, createFileRoute } from "@tanstack/react-router";
import z from "zod";
const loginSearchSchema = z.object({
stage: z.enum(['code', 'name']).optional(),
stage: z.enum(["code", "name"]).optional(),
number: z.string().optional(),
callback: z.string().optional()
callback: z.string().optional(),
});
export const Route = createFileRoute("/login")({
validateSearch: loginSearchSchema,
beforeLoad: async ({ context }) => {
if (context.auth?.user) {
throw redirect({ to: '/' })
throw redirect({ to: "/" });
}
},
component: () => {
@@ -22,6 +21,6 @@ export const Route = createFileRoute("/login")({
<LoginLayout>
<LoginFlow />
</LoginLayout>
)
}
})
);
},
});

View File

@@ -1,14 +1,15 @@
import { LoadingOverlay } from '@mantine/core'
import { signOut } from 'supertokens-web-js/recipe/passwordless'
import { redirect, createFileRoute } from '@tanstack/react-router'
import { authQueryConfig, defaultAuthData } from '@/contexts/auth-context'
import { LoadingOverlay } from "@mantine/core";
import { signOut } from "supertokens-web-js/recipe/passwordless";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { defaultAuthData } from "@/contexts/auth-context";
import { playerKeys } from "@/features/players/queries";
export const Route = createFileRoute('/logout')({
export const Route = createFileRoute("/logout")({
preload: false,
loader: async ({ context }) => {
await context.queryClient.setQueryData(authQueryConfig.queryKey, defaultAuthData);
await context.queryClient.setQueryData(playerKeys.auth, defaultAuthData);
await signOut();
throw redirect({ to: '/login' });
throw redirect({ to: "/login" });
},
pendingComponent: () => <LoadingOverlay visible />
})
pendingComponent: () => <LoadingOverlay visible />,
});

View File

@@ -122,7 +122,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
size="compact-sm"
onClick={toggleDetails}
>
{detailsOpened ? 'Hide' : 'Show'} technical details
{detailsOpened ? 'Hide' : 'Show'} stack trace
</Button>
<Collapse in={detailsOpened}>
<Code block mt="md" p="md">

View File

@@ -1,18 +1,43 @@
import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core';
import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
Paper,
} from "@mantine/core";
interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'size'> {
interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
name: string;
size?: number;
radius?: string | number;
withBorder?: boolean;
}
const Avatar = ({ name, size = 35, radius = '100%', withBorder = true, ...props }: AvatarProps) => {
return <Paper p={size / 20} radius={radius} withBorder={withBorder}>
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} w='fit-content' styles={{ image: {
objectFit: 'contain'
} }} {...props} />
const Avatar = ({
name,
size = 35,
radius = "100%",
withBorder = true,
...props
}: AvatarProps) => {
return (
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w="fit-content"
styles={{
image: {
objectFit: "contain",
},
}}
{...props}
/>
</Paper>
}
);
};
export default Avatar;

View File

@@ -1,11 +1,14 @@
import { Button as MantineButton, ButtonProps as MantineButtonProps } from '@mantine/core';
import { forwardRef, ComponentPropsWithoutRef } from 'react';
import {
Button as MantineButton,
ButtonProps as MantineButtonProps,
} from "@mantine/core";
import { forwardRef, ComponentPropsWithoutRef } from "react";
type ButtonProps = MantineButtonProps & ComponentPropsWithoutRef<'button'>;
type ButtonProps = MantineButtonProps & ComponentPropsWithoutRef<"button">;
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
return <MantineButton fullWidth ref={ref} {...props} />;
});
Button.displayName = 'Button';
Button.displayName = "Button";
export default Button;

View File

@@ -10,13 +10,18 @@ interface DateTimePickerProps {
[key: string]: any;
}
const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => {
const DateTimePicker = ({
value,
onChange,
label,
...rest
}: DateTimePickerProps) => {
const timeRef = useRef<HTMLInputElement>(null);
const currentDate = value ? new Date(value) : null;
const formatDate = (date: Date | null): string => {
if (!date) return "";
return date.toISOString().split('T')[0];
return date.toISOString().split("T")[0];
};
const formatTime = (date: Date | null): string => {
@@ -26,11 +31,11 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
const handleDateChange = (dateString: string | null) => {
if (!dateString) {
onChange('');
onChange("");
return;
}
const newDate = new Date(dateString + 'T00:00:00');
const newDate = new Date(dateString + "T00:00:00");
if (currentDate) {
newDate.setHours(currentDate.getHours());
@@ -44,7 +49,7 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
const timeValue = event.target.value;
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
const [hours, minutes] = timeValue.split(":").map(Number);
if (isNaN(hours) || isNaN(minutes)) return;
const baseDate = currentDate || new Date();

View File

@@ -10,21 +10,18 @@ interface ListButtonProps {
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
return (
<>
<UnstyledButton
w='100%'
p='md'
component={'button'}
onClick={onClick}
>
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
<Group>
<Icon weight='bold' size={20} />
<Text fw={500} size='md'>{label}</Text>
<CaretRightIcon style={{ marginLeft: 'auto' }} size={20} />
<Icon weight="bold" size={20} />
<Text fw={500} size="md">
{label}
</Text>
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
</Group>
</UnstyledButton>
<Divider />
</>
)
}
);
};
export default ListButton;

View File

@@ -14,17 +14,21 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
return (
<>
<NavLink
w='100%'
p='md'
component={'button'}
w="100%"
p="md"
component={"button"}
onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>}
leftSection={Icon && <Icon weight='bold' size={20} />}
label={
<Text fw={500} size="md">
{label}
</Text>
}
leftSection={Icon && <Icon weight="bold" size={20} />}
rightSection={<CaretRightIcon size={20} />}
/>
<Divider />
</>
)
}
);
};
export default ListLink;

View File

@@ -7,9 +7,19 @@ interface PageProps extends ContainerProps, React.PropsWithChildren {
const Page = ({ children, noPadding, ...props }: PageProps) => {
const { header } = useRouterConfig();
return <Container px={noPadding ? 0 : 'md'} pt={header.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
return (
<Container
px={noPadding ? 0 : "md"}
pt={header.collapsed ? 60 : 0}
pb={20}
m={0}
maw={600}
mx="auto"
{...props}
>
{children}
</Container>
}
);
};
export default Page;

View File

@@ -1,6 +1,6 @@
import { Input, InputProps, Group, Text } from '@mantine/core';
import { CheckFat, Phone } from '@phosphor-icons/react';
import { IMaskInput } from 'react-imask';
import { Input, InputProps, Group, Text } from "@mantine/core";
import { CheckFat, Phone } from "@phosphor-icons/react";
import { IMaskInput } from "react-imask";
interface PhoneNumberInputProps extends InputProps {
id: string;
@@ -11,24 +11,48 @@ interface PhoneNumberInputProps extends InputProps {
error?: string;
}
const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({ id, value, onChange, label, description, error, ...props }) => {
const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
id,
value,
onChange,
label,
description,
error,
...props
}) => {
return (
<Input.Wrapper id={id} label={label} description={description} error={error}>
<Input.Wrapper
id={id}
label={label}
description={description}
error={error}
>
<Input
id={id}
component={IMaskInput}
mask="(000) 000-0000"
leftSection={<Group gap={2}><Phone size={20} /> &nbsp; <Text c='dimmed' size='sm'>+1</Text></Group>}
leftSection={
<Group gap={2}>
<Phone size={20} /> &nbsp;{" "}
<Text c="dimmed" size="sm">
+1
</Text>
</Group>
}
leftSectionWidth={50}
leftSectionProps={{ style: { padding: 0 } }}
placeholder="(713) 867-5309"
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
rightSection={value?.length === 10 && <CheckFat color='green' size={20} weight='fill' />}
rightSection={
value?.length === 10 && (
<CheckFat color="green" size={20} weight="fill" />
)
}
value={value}
{...props}
/>
</Input.Wrapper>
);
}
};
export default PhoneNumberInput;

View File

@@ -1,8 +1,8 @@
import { Box, Container, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, useEffect } from "react";
import { Drawer as VaulDrawer } from 'vaul';
import { useMantineColorScheme } from '@mantine/core';
import styles from './styles.module.css';
import { Drawer as VaulDrawer } from "vaul";
import { useMantineColorScheme } from "@mantine/core";
import styles from "./styles.module.css";
interface DrawerProps extends PropsWithChildren {
title?: string;
@@ -10,44 +10,51 @@ interface DrawerProps extends PropsWithChildren {
onChange: (next: boolean) => void;
}
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
const colorScheme = useComputedColorScheme('light');
const Drawer: React.FC<DrawerProps> = ({
title,
children,
opened,
onChange,
}) => {
const colorScheme = useComputedColorScheme("light");
useEffect(() => {
const appElement = document.querySelector('.app') as HTMLElement;
const appElement = document.querySelector(".app") as HTMLElement;
if (!appElement) return;
let themeColorMeta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement;
let themeColorMeta = document.querySelector(
'meta[name="theme-color"]'
) as HTMLMetaElement;
if (!themeColorMeta) {
themeColorMeta = document.createElement('meta');
themeColorMeta.name = 'theme-color';
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)'
normal: "rgb(255,255,255)",
overlay: "rgb(153,153,153)",
},
dark: {
normal: 'rgb(36,36,36)',
overlay: 'rgb(22,22,22)'
}
normal: "rgb(36,36,36)",
overlay: "rgb(22,22,22)",
},
};
const currentColors = colors[colorScheme] || colors.light;
if (opened) {
appElement.classList.add('drawer-scaling');
appElement.classList.add("drawer-scaling");
themeColorMeta.content = currentColors.overlay;
} else {
appElement.classList.remove('drawer-scaling');
appElement.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal;
}
return () => {
appElement.classList.remove('drawer-scaling');
appElement.classList.remove("drawer-scaling");
themeColorMeta.content = currentColors.normal;
};
}, [opened, colorScheme]);
@@ -57,9 +64,17 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent}>
<Container flex={1} p='md'>
<Box mb='sm' bg='var(--mantine-color-gray-4)' w='3rem' h='0.375rem' ml='auto' mr='auto' style={{ borderRadius: '9999px' }} />
<Container mah='fit-content' mx='auto' maw='28rem' px={0}>
<Container flex={1} p="md">
<Box
mb="sm"
bg="var(--mantine-color-gray-4)"
w="3rem"
h="0.375rem"
ml="auto"
mr="auto"
style={{ borderRadius: "9999px" }}
/>
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title>
{children}
</Container>
@@ -67,7 +82,7 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
</VaulDrawer.Content>
</VaulDrawer.Portal>
</VaulDrawer.Root>
)
}
);
};
export default Drawer;

View File

@@ -8,9 +8,13 @@ interface ModalProps extends PropsWithChildren {
}
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
<MantineModal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>}>
<MantineModal
opened={opened}
onClose={onClose}
title={<Title order={3}>{title}</Title>}
>
{children}
</MantineModal>
)
);
export default Modal;

View File

@@ -17,11 +17,19 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
const SheetComponent = isMobile ? Drawer : Modal;
return (
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
<Box mah='70vh'>
{children}
</Box>
<SheetComponent
title={title}
opened={opened}
onChange={onChange}
onClose={handleClose}
>
<ScrollArea
style={{ flex: 1 }}
scrollbarSize={8}
scrollbars="y"
type="scroll"
>
<Box mah="70vh">{children}</Box>
</ScrollArea>
</SheetComponent>
);

View File

@@ -32,7 +32,7 @@ const SlidePanelField = ({
const context = useContext(SlidePanelContext);
if (!context) {
throw new Error('SlidePanelField must be used within a SlidePanel');
throw new Error("SlidePanelField must be used within a SlidePanel");
}
const handleClick = () => {
@@ -64,24 +64,49 @@ const SlidePanelField = ({
<Box>
<UnstyledButton
onClick={handleClick}
p='sm'
p="sm"
style={{
width: '100%',
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
borderRadius: 'var(--mantine-radius-md)',
backgroundColor: 'var(--mantine-color-body)',
textAlign: 'left',
width: "100%",
border: error
? "1px solid var(--mantine-color-error)"
: "1px solid var(--mantine-color-dimmed)",
borderRadius: "var(--mantine-radius-md)",
backgroundColor: "var(--mantine-color-body)",
textAlign: "left",
}}
>
<Flex justify="space-between" align="center">
<Stack>
<Text size="sm" fw={500}>{label}{withAsterisk && <Text span size="sm" c='var(--mantine-color-error)' fw={500} ml={4}>*</Text>}</Text>
<Text size="sm" c='dimmed'>{displayValue()}</Text>
<Text size="sm" fw={500}>
{label}
{withAsterisk && (
<Text
span
size="sm"
c="var(--mantine-color-error)"
fw={500}
ml={4}
>
*
</Text>
)}
</Text>
<Text size="sm" c="dimmed">
{displayValue()}
</Text>
</Stack>
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
<CaretRightIcon
size={24}
weight="thin"
style={{ marginRight: "12px" }}
/>
</Flex>
</UnstyledButton>
{error && <Text size="xs" c='var(--mantine-color-error)' fw={500} ml={4} mt={4}>{error}</Text>}
{error && (
<Text size="xs" c="var(--mantine-color-error)" fw={500} ml={4} mt={4}>
{error}
</Text>
)}
</Box>
);
};

View File

@@ -1,6 +1,13 @@
import { Box, Text, Group, ActionIcon, ScrollArea, Divider } from "@mantine/core";
import {
Box,
Text,
Group,
ActionIcon,
ScrollArea,
Divider,
} from "@mantine/core";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { useState, ReactNode} from "react";
import { useState, ReactNode } from "react";
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
import Button from "@/components/button";
@@ -15,11 +22,6 @@ interface SlidePanelProps {
loading?: boolean;
}
/**
* SlidePanel is a form component meant to be used inside a drawer/modal
* It is used to create a form with multiple views/panels that slides in from the side
* Use with SlidePanelField for an extra panel
*/
const SlidePanel = ({
children,
onSubmit,
@@ -28,7 +30,7 @@ const SlidePanel = ({
cancelText = "Cancel",
maxHeight = "70vh",
formProps = {},
loading = false
loading = false,
}: SlidePanelProps) => {
const [isOpen, setIsOpen] = useState(false);
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
@@ -60,45 +62,53 @@ const SlidePanel = ({
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
<Box
style={{
position: 'relative',
position: "relative",
height: maxHeight,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
<Box
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: isOpen ? 'translateX(-100%)' : 'translateX(0)',
transition: 'transform 0.3s ease-in-out',
display: 'flex',
flexDirection: 'column'
transform: isOpen ? "translateX(-100%)" : "translateX(0)",
transition: "transform 0.3s ease-in-out",
display: "flex",
flexDirection: "column",
}}
>
<form
{...formProps}
onSubmit={handleFormSubmit}
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
display: "flex",
flexDirection: "column",
height: "100%",
...formProps.style,
}}
>
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='always'>
<Box p="md">
{children}
</Box>
<ScrollArea
style={{ flex: 1 }}
scrollbarSize={8}
scrollbars="y"
type="always"
>
<Box p="md">{children}</Box>
</ScrollArea>
<Box p="sm">
<Group gap="md">
<Button type="submit" fullWidth loading={loading} disabled={loading}>
<Button
type="submit"
fullWidth
loading={loading}
disabled={loading}
>
{submitText}
</Button>
{onCancel && (
@@ -120,32 +130,41 @@ const SlidePanel = ({
<Box
style={{
position: 'absolute',
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.3s ease-in-out',
backgroundColor: 'var(--mantine-color-body)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transform: isOpen ? "translateX(0)" : "translateX(100%)",
transition: "transform 0.3s ease-in-out",
backgroundColor: "var(--mantine-color-body)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
{panelConfig && (
<>
<Group justify="space-between" p="md" align="center" w='100%'>
<Group justify="space-between" p="md" align="center" w="100%">
<ActionIcon variant="transparent" onClick={closePanel}>
<ArrowLeftIcon size={24} />
</ActionIcon>
<Text fw={500}>{panelConfig.title}</Text>
<ActionIcon variant="transparent" color="green" onClick={handleConfirm}>
<ActionIcon
variant="transparent"
color="green"
onClick={handleConfirm}
>
<CheckIcon size={24} />
</ActionIcon>
</Group>
<Divider h='1px' w='100%' bg='var(--mantine-color-dimmed)' my='xs'/>
<Divider
h="1px"
w="100%"
bg="var(--mantine-color-dimmed)"
my="xs"
/>
<Box>
<panelConfig.Component

View File

@@ -1,6 +1,13 @@
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode, useRef, useCallback, useMemo } from "react";
import {
useState,
useEffect,
ReactNode,
useRef,
useCallback,
useMemo,
} from "react";
import { useRouter } from "@tanstack/react-router";
interface TabItem {
@@ -14,15 +21,21 @@ interface SwipeableTabsProps {
onTabChange?: (index: number, tab: TabItem) => void;
}
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
function SwipeableTabs({
tabs,
defaultTab = 0,
onTabChange,
}: SwipeableTabsProps) {
const router = useRouter();
const search = router.state.location.search as any;
const [embla, setEmbla] = useState<any>(null);
const getActiveTabFromUrl = useCallback(() => {
const urlTab = search?.tab;
if (typeof urlTab === 'string') {
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
if (typeof urlTab === "string") {
const tabIndex = tabs.findIndex(
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
);
return tabIndex !== -1 ? tabIndex : defaultTab;
}
return defaultTab;
@@ -32,9 +45,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
const [carouselHeight, setCarouselHeight] = useState<number | "auto">("auto");
const changeTab = useCallback((index: number) => {
const changeTab = useCallback(
(index: number) => {
if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index);
@@ -42,12 +56,14 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
onTabChange?.(index, tabs[index]);
const tabLabel = tabs[index].label.toLowerCase();
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
const url = new URL(window.location.href);
url.searchParams.set('tab', tabLabel);
window.history.replaceState(null, '', url.toString());
url.searchParams.set("tab", tabLabel);
window.history.replaceState(null, "", url.toString());
}
}, [activeTab, tabs, embla, onTabChange]);
},
[activeTab, tabs, embla, onTabChange]
);
const handleEmblaSelect = useCallback(() => {
if (!embla) return;
@@ -78,13 +94,19 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
}
}, [activeTab]);
const setControlRef = useCallback((index: number) => (node: HTMLSpanElement | null) => {
const setControlRef = useCallback(
(index: number) => (node: HTMLSpanElement | null) => {
controlsRefs.current[index] = node;
}, []);
},
[]
);
const setSlideRef = useCallback((index: number) => (node: HTMLDivElement | null) => {
const setSlideRef = useCallback(
(index: number) => (node: HTMLDivElement | null) => {
slideRefs.current[index] = node;
}, []);
},
[]
);
return (
<Box>
@@ -93,10 +115,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
pos="sticky"
top={0}
style={{
display: 'flex',
marginBottom: 'var(--mantine-spacing-md)',
display: "flex",
marginBottom: "var(--mantine-spacing-md)",
zIndex: 100,
backgroundColor: 'var(--mantine-color-body)'
backgroundColor: "var(--mantine-color-body)",
}}
>
<FloatingIndicator
@@ -104,9 +126,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
parent={rootRef}
styles={{
root: {
borderBottom: '2px solid var(--mantine-primary-color-filled)',
paddingInline: '0.5rem'
}
borderBottom: "2px solid var(--mantine-primary-color-filled)",
paddingInline: "0.5rem",
},
}}
/>
{tabs.map((tab, index) => (
@@ -115,15 +137,16 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
onClick={() => changeTab(index)}
style={{
flex: 1,
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
textAlign: 'center',
color: activeTab === index
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-text)',
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-md)",
textAlign: "center",
color:
activeTab === index
? "var(--mantine-primary-color-filled)"
: "var(--mantine-color-text)",
fontWeight: activeTab === index ? 600 : 400,
transition: 'color 200ms ease, font-weight 200ms ease',
backgroundColor: 'transparent',
border: 'none',
transition: "color 200ms ease, font-weight 200ms ease",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
>
@@ -131,9 +154,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
size="sm"
component="span"
style={{
display: 'inline-block',
paddingInline: '1rem',
paddingBottom: '0.25rem'
display: "inline-block",
paddingInline: "1rem",
paddingBottom: "0.25rem",
}}
ref={setControlRef(index)}
>
@@ -150,17 +173,14 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
slideSize="100%"
initialSlide={activeTab}
style={{
overflow: 'hidden',
height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
transition: 'height 300ms ease'
overflow: "hidden",
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
transition: "height 300ms ease",
}}
>
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box
ref={setSlideRef(index)}
style={{ height: 'auto' }}
>
<Box ref={setSlideRef(index)} style={{ height: "auto" }}>
{tab.content}
</Box>
</Carousel.Slide>

View File

@@ -1,14 +1,9 @@
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react";
import { MantineColor, MantineColorScheme } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server";
import { useQueryClient } from "@tanstack/react-query";
import { Player } from "@/features/players/types";
import { playerKeys, playerQueries, useMe } from "@/features/players/queries";
const queryKey = ['auth'];
export const authQueryConfig = {
queryKey,
queryFn: fetchMe
}
interface AuthData {
user: Player | undefined;
@@ -33,10 +28,10 @@ const AuthContext = createContext<AuthContextType>({
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<AuthData>(authQueryConfig);
const { data } = useMe();
const set = useCallback((updates: Partial<AuthData>) => {
queryClient.setQueryData(queryKey, (oldData: AuthData | undefined) => {
queryClient.setQueryData(playerKeys.auth, (oldData: AuthData | undefined) => {
const currentData = oldData || defaultAuthData;
return {
...currentData,
@@ -48,10 +43,6 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
});
}, [queryClient]);
if (isLoading) {
return <p>Loading...</p>
}
return (
<AuthContext
value={{

View File

@@ -1,14 +1,32 @@
import { List } from "@mantine/core";
import ListLink from "@/components/list-link";
import { DatabaseIcon, TreeStructureIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
const AdminPage = () => {
return (
<List>
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
<ListLink label="Bracket Preview" Icon={TreeStructureIcon} to="/admin/preview" />
<ListLink
label="Manage Tournaments"
Icon={TrophyIcon}
to="/admin/tournaments"
/>
<ListButton
label="Open Pocketbase"
Icon={DatabaseIcon}
onClick={() =>
window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")
}
/>
<ListLink
label="Bracket Preview"
Icon={TreeStructureIcon}
to="/admin/preview"
/>
</List>
);
};

View File

@@ -1,13 +1,12 @@
import { List } from "@mantine/core";
import { useSuspenseQuery } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
import { useTournaments } from "@/features/tournaments/queries";
import ListLink from "@/components/list-link";
const ManageTournaments = () => {
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
const { data: tournaments } = useTournaments();
return (
<List>
{tournaments.map(t => (
{tournaments.map((t) => (
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
))}
</List>

View File

@@ -1,5 +1,5 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin');
export const logger = new Logger("Admin");
export { default as AdminPage } from './components/admin-page';
export { default as AdminPage } from "./components/admin-page";

View File

@@ -5,15 +5,15 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
import { BracketMaps } from "../utils/bracket-maps";
interface BracketProps {
winners: Match[][],
losers?: Match[][],
bracketMaps: BracketMaps | null
winners: Match[][];
losers?: Match[][];
bracketMaps: BracketMaps | null;
}
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
const height = useAppShellHeight();
if (!bracketMaps) return <p>Bracket not available.</p>
if (!bracketMaps) return <p>Bracket not available.</p>;
return (
<ScrollArea
@@ -31,14 +31,14 @@ const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
</Text>
<BracketView bracket={winners} bracketMaps={bracketMaps} />
</div>
{
losers && <div>
{losers && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
</Text>
<BracketView bracket={losers} bracketMaps={bracketMaps} />
</div>
}
)}
</ScrollArea>
);
};

View File

@@ -1,32 +1,42 @@
import { Flex, Text } from '@mantine/core';
import React from 'react';
import { SeedBadge } from './seed-badge';
import { Flex, Text } from "@mantine/core";
import React from "react";
import { SeedBadge } from "./seed-badge";
interface MatchSlotProps {
slot: any;
getParentMatchOrder: (parentLid: number) => number | string;
}
export const MatchSlot: React.FC<MatchSlotProps> = ({ slot, getParentMatchOrder }) => {
export const MatchSlot: React.FC<MatchSlotProps> = ({
slot,
getParentMatchOrder,
}) => {
const renderSlotContent = () => {
if (slot?.seed) {
return slot.team ? (
<Text size='xs'>{slot.team.name}</Text>
<Text size="xs">{slot.team.name}</Text>
) : (
<Text size='xs' c='dimmed'>Team {slot.seed}</Text>
<Text size="xs" c="dimmed">
Team {slot.seed}
</Text>
);
}
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
return (
<Text c='dimmed' size='xs'>
{slot.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(slot.parent_lid)}
<Text c="dimmed" size="xs">
{slot.loser ? "Loser" : "Winner"} of Match{" "}
{getParentMatchOrder(slot.parent_lid)}
</Text>
);
}
if (slot) {
return <Text c='dimmed' size='xs' fs='italic'>TBD</Text>;
return (
<Text c="dimmed" size="xs" fs="italic">
TBD
</Text>
);
}
return null;
@@ -35,9 +45,7 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({ slot, getParentMatchOrder
return (
<Flex align="stretch">
{slot?.seed && <SeedBadge seed={slot.seed} />}
<div style={{ flex: 1, padding: '4px 8px' }}>
{renderSlotContent()}
</div>
<div style={{ flex: 1, padding: "4px 8px" }}>{renderSlotContent()}</div>
</Flex>
);
};

View File

@@ -7,7 +7,7 @@ import {
Loader,
} from "@mantine/core";
import { useEffect, useState } from "react";
import { bracketQueries } from "../queries";
import { bracketQueries, useBracketPreview } from "../queries";
import { useQuery } from "@tanstack/react-query";
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
import { BracketData, Match } from "../types";
@@ -21,9 +21,7 @@ interface PreviewTeam {
export const PreviewBracket: React.FC = () => {
const [teamCount, setTeamCount] = useState(20);
const { data, isLoading, error } = useQuery(
bracketQueries.preview(teamCount)
);
const { data, isLoading, error } = useBracketPreview(teamCount);
const [teams, setTeams] = useState<PreviewTeam[]>([]);
@@ -45,7 +43,7 @@ export const PreviewBracket: React.FC = () => {
useEffect(() => {
if (!data || teams.length === 0) return;
const maps = createBracketMaps(data as BracketData);
const maps = createBracketMaps(data);
setBracketMaps(maps);
const mapBracket = (bracket: Match[][]) => {
@@ -88,7 +86,7 @@ export const PreviewBracket: React.FC = () => {
return (
<Container p={0} w="100%" style={{ userSelect: "none" }}>
<Flex w="100%" justify="space-between" align="center" h="3rem">
<Group gap="sm" mx='auto'>
<Group gap="sm" mx="auto">
<Text size="sm" c="dimmed">
Teams:
</Text>

View File

@@ -1,5 +1,5 @@
import { Text } from '@mantine/core';
import React from 'react';
import { Text } from "@mantine/core";
import React from "react";
interface SeedBadgeProps {
seed: number;
@@ -13,14 +13,14 @@ export const SeedBadge: React.FC<SeedBadgeProps> = ({ seed }) => {
py="4"
bg="var(--mantine-color-default-hover)"
style={{
width: '32px',
textAlign: 'center',
color: 'var(--mantine-color-text)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderTopLeftRadius: 'var(--mantine-radius-default)',
borderBottomLeftRadius: 'var(--mantine-radius-default)',
width: "32px",
textAlign: "center",
color: "var(--mantine-color-text)",
display: "flex",
alignItems: "center",
justifyContent: "center",
borderTopLeftRadius: "var(--mantine-radius-default)",
borderBottomLeftRadius: "var(--mantine-radius-default)",
}}
>
{seed}

View File

@@ -1,4 +1,4 @@
import { Flex, Text, Select, Card } from '@mantine/core';
import { Flex, Text, Select, Card } from "@mantine/core";
interface Team {
id: string;
@@ -13,11 +13,11 @@ interface SeedListProps {
export function SeedList({ teams, onSeedChange }: SeedListProps) {
const seedOptions = teams.map((_, index) => ({
value: index.toString(),
label: `Seed ${index + 1}`
label: `Seed ${index + 1}`,
}));
return (
<Flex direction='column' gap={8}>
<Flex direction="column" gap={8}>
{teams.map((team, index) => (
<Card key={team.id} withBorder p="xs">
<Flex align="center" gap="xs" justify="space-between">

View File

@@ -1,13 +1,18 @@
import { queryOptions } from "@tanstack/react-query";
import { previewBracket } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { BracketData } from "./types";
const bracketKeys = {
preview: (teams: number) => ['bracket', 'preview', teams] as const,
preview: (teams: number) => ["bracket", "preview", teams] as const,
};
export const bracketQueries = {
preview: (teams: number) => queryOptions({
preview: (teams: number) => ({
queryKey: bracketKeys.preview(teams),
queryFn: () => previewBracket({ data: teams }),
}),
};
export const useBracketPreview = (teams: number) =>
useServerSuspenseQuery<BracketData>(bracketQueries.preview(teams));

View File

@@ -2,17 +2,20 @@ import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { Logger } from "@/lib/logger";
import brackets from './utils';
import brackets from "./utils";
import { BracketData } from "./types";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
const logger = new Logger("Bracket Generation")
const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn()
.validator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => {
logger.info('Generating bracket', teams);
.handler(async ({ data: teams }) =>
toServerResult(async () => {
logger.info("Generating bracket", teams);
if (!Object.keys(brackets).includes(teams.toString()))
throw Error("Bracket not available")
throw Error("Bracket not available");
return brackets[teams as keyof typeof brackets] as BracketData;
});
})
);

View File

@@ -1,4 +1,3 @@
export interface Slot {
seed?: number;
team?: any;

View File

@@ -11,8 +11,8 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
const matchByOrder = new Map<number, Match>();
const allMatches: Match[] = [];
[...bracketData.winners, ...bracketData.losers].forEach(round => {
round.forEach(match => {
[...bracketData.winners, ...bracketData.losers].forEach((round) => {
round.forEach((match) => {
matchByLid.set(match.lid, match);
if (match.order !== null && match.order !== undefined) {
@@ -26,14 +26,20 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
return {
matchByLid,
matchByOrder,
allMatches
allMatches,
};
}
export function getMatchByLid(maps: BracketMaps, lid: number): Match | undefined {
export function getMatchByLid(
maps: BracketMaps,
lid: number
): Match | undefined {
return maps.matchByLid.get(lid);
}
export function getMatchByOrder(maps: BracketMaps, order: number): Match | undefined {
export function getMatchByOrder(
maps: BracketMaps,
order: number
): Match | undefined {
return maps.matchByOrder.get(order);
}

View File

@@ -1,15 +1,15 @@
/**
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
*/
import b12 from '../../../../scripts/brackets/12.json';
import b13 from '../../../../scripts/brackets/13.json';
import b14 from '../../../../scripts/brackets/14.json';
import b15 from '../../../../scripts/brackets/15.json';
import b16 from '../../../../scripts/brackets/16.json';
import b17 from '../../../../scripts/brackets/17.json';
import b18 from '../../../../scripts/brackets/18.json';
import b19 from '../../../../scripts/brackets/19.json';
import b20 from '../../../../scripts/brackets/20.json';
import b12 from "../../../../scripts/brackets/12.json";
import b13 from "../../../../scripts/brackets/13.json";
import b14 from "../../../../scripts/brackets/14.json";
import b15 from "../../../../scripts/brackets/15.json";
import b16 from "../../../../scripts/brackets/16.json";
import b17 from "../../../../scripts/brackets/17.json";
import b18 from "../../../../scripts/brackets/18.json";
import b19 from "../../../../scripts/brackets/19.json";
import b20 from "../../../../scripts/brackets/20.json";
export default {
12: b12,
@@ -21,4 +21,4 @@ export default {
18: b18,
19: b19,
20: b20,
}
};

View File

@@ -8,11 +8,12 @@ interface NavLinkProps {
href: string;
label: string;
Icon: Icon;
include?: string[];
}
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
export const NavLink = ({ href, label, Icon, include }: NavLinkProps) => {
const router = useRouterState();
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
const isActive = useMemo(() => (router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/')) || include?.includes(router.location.pathname), [router.location.pathname, href]);
return (
<Box component={Link} to={href}

View File

@@ -8,6 +8,7 @@ import { useLinks } from "../hooks/use-links";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const links = useLinks(user?.id, roles);
if (isMobile) return (

View File

@@ -3,7 +3,6 @@ import { useMemo } from "react";
export const useLinks = (userId: string | undefined, roles: string[]) =>
useMemo(() => {
if (!userId) throw new Error("userId is undefined")
const links = [
{
label: 'Home',
@@ -23,7 +22,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
{
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
Icon: UserCircleIcon,
include: ['/settings']
}
]

View File

@@ -1,12 +1,11 @@
import { useState, FormEventHandler, useMemo } from 'react';
import { ArrowLeftIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
import ExistingPlayerButton from './existing-player-button';
import NewPlayerButton from './new-player-button';
import { Player } from '@/features/players/types';
import { toast } from 'sonner';
import { playerQueries } from '@/features/players/queries';
import toast from '@/lib/sonner'
import { useUnassociatedPlayers } from '@/features/players/queries';
import useCreateUser from '../../hooks/use-create-user';
import Button from '@/components/button';
@@ -17,7 +16,7 @@ enum PlayerPromptStage {
const PlayerPrompt = () => {
const [stage, setStage] = useState<PlayerPromptStage>();
const playersQuery = useQuery(playerQueries.unassociated());
const playersQuery = useUnassociatedPlayers();
const { mutate: createUser, isPending } = useCreateUser();
const players = playersQuery.data;
const [player, setPlayer] = useState<Player>();

View File

@@ -2,8 +2,8 @@ import { consumeCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server";
import { useNavigate } from "@tanstack/react-router";
import { authQueryConfig } from "@/contexts/auth-context";
import toast from '@/lib/sonner'
import { playerKeys } from "@/features/players/queries";
const useConsumeCode = (onWrongCode: () => void) => {
const navigate = useNavigate();
@@ -13,9 +13,9 @@ const useConsumeCode = (onWrongCode: () => void) => {
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
onSuccess: async (data) => {
if (data.status === 'OK') {
const data = await fetchMe();
queryClient.setQueryData(authQueryConfig.queryKey, data);
if (!data || !data.user) {
const response = await fetchMe();
queryClient.setQueryData(playerKeys.auth, data);
if (!response.success || !response.data.user) {
navigate({ to: '/login', search: { stage: 'name' } });
} else {
toast.success('Successfully logged in. Welcome back!');

View File

@@ -1,41 +1,26 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { authQueryConfig } from "@/contexts/auth-context";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { associatePlayer, createPlayer } from "@/features/players/server";
import toast from '@/lib/sonner';
import { playerKeys } from "@/features/players/queries";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateUser = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { first_name: string, last_name: string } | string) => {
if (typeof data === 'string') {
return associatePlayer({ data });
} else {
return createPlayer({ data });
}
},
return useServerMutation({
mutationFn: (data: { first_name: string, last_name: string } | string) =>
typeof data === 'string' ?
associatePlayer({ data })
: createPlayer({ data }),
successMessage: 'Account created successfully!',
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your account. Please try again later.');
navigate({ to: '/login' });
} else {
queryClient.setQueryData(authQueryConfig.queryKey, (old: any) => ({
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
...old,
user: data
}));
toast.success('Account created successfully!');
navigate({ to: '/' });
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to create an account. Please try again later.');
}
},
});
};

View File

@@ -1,15 +1,15 @@
import { Box, Button, Text, Title } from "@mantine/core";
import { Box, Text } from "@mantine/core";
import Header from "./header";
import { testEvent } from "@/utils/test-event";
import { Player } from "@/features/players/types";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer } from "../../queries";
interface ProfileProps {
player: Player;
id: string;
}
const Profile = ({ player }: ProfileProps) => {
const Profile = ({ id }: ProfileProps) => {
const { data: player } = usePlayer(id);
const tabs = [
{
label: "Overview",
@@ -21,9 +21,7 @@ const Profile = ({ player }: ProfileProps) => {
},
{
label: "Teams",
content: <>
<TeamList teams={player.teams || []} />
</>
content: <Text p="md">Teams will go here</Text>
}
];

View File

@@ -1,11 +1,10 @@
import { updatePlayer } from "@/features/players/server";
import { useMutation } from "@tanstack/react-query";
import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import toast from "@/lib/sonner";
import { useRouter } from "@tanstack/react-router";
import { Player } from "../../types";
import Button from "@/components/button";
import { useOptimisticMutation } from "@/lib/tanstack-query/hooks";
import { playerKeys } from "../../queries";
interface NameUpdateFormProps {
player: Player;
@@ -13,8 +12,6 @@ interface NameUpdateFormProps {
}
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
const router = useRouter();
const form = useForm({
initialValues: {
first_name: player.first_name,
@@ -23,30 +20,32 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
validate: {
first_name: (value: string | undefined) => {
if (!value || value.length === 0) return "First name is required";
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
return "First name must be 3-20 characters long and contain only letters";
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
return "First name must be 2-20 characters long and contain only letters";
},
last_name: (value: string | undefined) => {
if (!value || value.length === 0) return "Last name is required";
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
return "Last name must be 3-20 characters long and contain only letters";
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
return "Last name must be 2-20 characters long and contain only letters";
},
},
});
const { mutate: updateName, isPending } = useMutation({
const { mutate: updateName, isPending } = useOptimisticMutation({
mutationFn: async (data: { first_name: string; last_name: string }) =>
await updatePlayer({ data }),
onSuccess: () => {
toggle();
toast.success("Name updated successfully!");
router.invalidate();
},
onError: () => {
toast.error(
"There was an issue updating your name. Please try again later."
);
onSuccess: toggle,
onError: toggle,
successMessage: "Name updated successfully!",
optimisticUpdate: (oldData, variables) => {
if (!oldData) return oldData;
return {
...oldData,
first_name: variables.first_name,
last_name: variables.last_name,
};
},
queryKey: playerKeys.details(player.id)
});
const handleSubmit = async (data: {

View File

@@ -1,23 +1,49 @@
import { queryOptions } from "@tanstack/react-query";
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
const playerKeys = {
list: ['players', 'list'] as const,
details: (id: string) => ['players', 'details', id] as const,
unassociated: ['players','unassociated'] as const,
export const playerKeys = {
auth: ['auth'],
list: ['players', 'list'],
details: (id: string) => ['players', 'details', id],
unassociated: ['players','unassociated'],
};
export const playerQueries = {
list: () => queryOptions({
auth: () => ({
queryKey: playerKeys.auth,
queryFn: async () => await fetchMe()
}),
list: () => ({
queryKey: playerKeys.list,
queryFn: listPlayers,
queryFn: async () => await listPlayers()
}),
details: (id: string) => queryOptions({
details: (id: string) => ({
queryKey: playerKeys.details(id),
queryFn: () => getPlayer({ data: id }),
queryFn: async () => await getPlayer({ data: id })
}),
unassociated: () => queryOptions({
unassociated: () => ({
queryKey: playerKeys.unassociated,
queryFn: getUnassociatedPlayers,
queryFn: async () => await getUnassociatedPlayers()
}),
};
export const useMe = () => {
const { queryKey, queryFn } = playerQueries.auth();
return useServerSuspenseQuery({
queryKey,
queryFn,
options: {
staleTime: 0,
refetchOnMount: true
}
});
};
export const usePlayer = (id: string) =>
useServerSuspenseQuery(playerQueries.details(id));
export const usePlayers = () =>
useServerSuspenseQuery(playerQueries.list());
export const useUnassociatedPlayers = () =>
useServerSuspenseQuery(playerQueries.unassociated());

View File

@@ -1,60 +1,48 @@
import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { Player, playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { logger } from ".";
import { getWebRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const fetchMe = createServerFn()
.handler(async ({ response }) => {
.handler(async ({ response }) =>
toServerResult(async () => {
const request = getWebRequest();
const { context } = await verifySuperTokensSession(request, response);
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
try {
await pbAdmin.authPromise;
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
logger.info('Fetched player', result);
return {
user: result || undefined,
roles: context.roles,
metadata: context.metadata
};
} catch (error) {
logger.error('Error fetching player:', error);
return { user: undefined, roles: context.roles, metadata: context.metadata };
}
});
})
);
export const getPlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => {
try {
const player = await pbAdmin.getPlayer(data);
return player;
} catch (error) {
logger.error('Error getting player', error);
return undefined;
}
});
.handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
);
export const updatePlayer = createServerFn()
.validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
try {
// Find the player by authId first
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!existing) return;
// Update the player
const updatedPlayer = await pbAdmin.updatePlayer(
existing.id!,
{
@@ -68,20 +56,17 @@ export const updatePlayer = createServerFn()
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
return updatedPlayer;
} catch (error) {
logger.error('Error updating player name', error);
return undefined;
}
});
})
);
export const createPlayer = createServerFn()
.validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
try {
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (existing) return;
@@ -96,20 +81,17 @@ export const createPlayer = createServerFn()
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
logger.info('Created player', newPlayer);
return newPlayer;
} catch (error) {
logger.error('Error creating player', error);
return undefined;
}
});
})
);
export const associatePlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
try {
await pbAdmin.updatePlayer(data, {
auth_id: userAuthId
});
@@ -119,30 +101,17 @@ export const associatePlayer = createServerFn()
const player = await pbAdmin.getPlayer(data);
logger.info('Associated player', player);
return player;
} catch (error) {
logger.error('Error associating player', error);
return undefined;
}
});
})
);
export const listPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.listPlayers();
} catch (error) {
logger.error('Error listing players', error);
return [];
}
});
.handler(async () =>
toServerResult(pbAdmin.listPlayers)
);
export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.getUnassociatedPlayers();
} catch (error) {
logger.error('Error getting unassociated players', error);
return [];
}
});
.handler(async () =>
toServerResult(pbAdmin.getUnassociatedPlayers)
);

View File

@@ -13,8 +13,8 @@ export interface Player {
export const playerInputSchema = z.object({
auth_id: z.string().optional(),
first_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 3-20 characters long and contain only letters and spaces"),
last_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-20 characters long and contain only letters and spaces"),
first_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 2-20 characters long and contain only letters and spaces"),
last_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 2-20 characters long and contain only letters and spaces"),
});
export const playerUpdateSchema = playerInputSchema.partial();

View File

@@ -1,23 +1,32 @@
import { Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import React from "react";
interface TeamListItemProps { team: Team }
interface TeamListItemProps {
team: Team;
}
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
const playerNames = useMemo(() => team.players?.map(p => `${p.first_name} ${p.last_name}`) || [], [team.players]);
const playerNames = useMemo(
() => team.players?.map((p) => `${p.first_name} ${p.last_name}`) || [],
[team.players]
);
return <>
return (
<>
<Stack gap={0}>
<Text fw={500}>{`${team.name}`}</Text>
{
playerNames.map(name => <Text size='xs' c='dimmed'>{name}</Text>)
}
{playerNames.map((name) => (
<Text size="xs" c="dimmed">
{name}
</Text>
))}
</Stack>
</>
})
);
});
interface TeamListProps {
teams: Team[];
@@ -27,30 +36,41 @@ interface TeamListProps {
const TeamList = ({ teams, loading = false }: TeamListProps) => {
const navigate = useNavigate();
const handleClick = useCallback((teamId: string) =>
navigate({ to: `/teams/${teamId}` }), [navigate]);
const handleClick = useCallback(
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
[navigate]
);
if (loading) return <List>
if (loading)
return (
<List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
<ListItem
key={`skeleton-${i}`}
py="xs"
icon={<Skeleton height={40} width={40} />}
>
<Skeleton height={35} width={200} />
</ListItem>
))}
</List>
);
return <List>
return (
<List>
{teams?.map((team) => (
<ListItem key={team.id}
py='xs'
icon={<Avatar radius='sm' size={40} name={`${team.name}`} />}
style={{ cursor: 'pointer' }}
<ListItem
key={team.id}
py="xs"
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />}
style={{ cursor: "pointer" }}
onClick={() => handleClick(team.id)}
>
<TeamListItem team={team} />
</ListItem>
))}
</List>
}
);
};
export default TeamList;

View File

@@ -3,20 +3,23 @@ import Avatar from "@/components/avatar";
import { Team } from "../../types";
interface HeaderProps {
team: Team;
name: string;
logo?: string;
}
const Header = ({ team }: HeaderProps) => {
const Header = ({ name, logo }: HeaderProps) => {
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Avatar radius='sm' name={team.name} size={125} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{team.name}</Title>
<Flex px="xl" w="100%" align="self-end" gap="md">
<Avatar radius="sm" name={name} size={125} />
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
<Title ta="center" order={2}>
{name}
</Title>
</Flex>
</Flex>
</>
)
);
};
export default Header;

View File

@@ -1,39 +1,43 @@
import { Box, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import { Team } from "../../types";
import PlayerList from "@/features/players/components/player-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import { useTeam } from "../../queries";
interface ProfileProps {
team: Team;
id: string;
}
const TeamProfile = ({ team }: ProfileProps) => {
console.log(team);
const TeamProfile = ({ id }: ProfileProps) => {
const { data: team } = useTeam(id);
if (!team) return <Text p="md">Team not found</Text>;
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text>
content: <Text p="md">Stats/Badges will go here</Text>,
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>
content: <Text p="md">Matches feed will go here</Text>,
},
{
label: "Tournaments",
content: <>
content: (
<>
<TournamentList tournaments={team.tournaments || []} />
</>
}
),
},
];
return <>
<Header team={team} />
<Box m='sm' mt='lg'>
return (
<>
<Header name={team.name} logo={team.logo} />
<Box m="sm" mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>;
</>
);
};
export default TeamProfile;

View File

@@ -1,13 +1,16 @@
import { queryOptions } from "@tanstack/react-query";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getTeam } from "./server";
const teamKeys = {
export const teamKeys = {
details: (id: string) => ['teams', 'details', id] as const,
};
export const teamQueries = {
details: (id: string) => queryOptions({
details: (id: string) => ({
queryKey: teamKeys.details(id),
queryFn: () => getTeam({ data: id }),
}),
};
export const useTeam = (id: string) =>
useServerSuspenseQuery(teamQueries.details(id));

View File

@@ -1,13 +1,12 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from ".";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const getTeam = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => {
logger.info('Getting team', teamId);
return await pbAdmin.getTeam(teamId);
});
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeam(teamId))
);

View File

@@ -1,5 +1,5 @@
import { Player } from "@/features/players/types";
import { z } from 'zod';
import { z } from "zod";
import { Tournament } from "../tournaments/types";
export interface Team {
@@ -22,11 +22,18 @@ export interface Team {
tournaments: Tournament[];
}
export const teamInputSchema = z.object({
export const teamInputSchema = z
.object({
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
logo: z.file("Invalid logo").optional(),
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
primary_color: z
.string()
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
.optional(),
accent_color: z
.string()
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
.optional(),
song_id: z.string().max(255).optional(),
song_name: z.string().max(255).optional(),
song_artist: z.string().max(255).optional(),
@@ -35,7 +42,8 @@ export const teamInputSchema = z.object({
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
}).refine(
})
.refine(
(data) => {
if (data.song_start && data.song_end) {
return data.song_end > data.song_start;
@@ -43,7 +51,7 @@ export const teamInputSchema = z.object({
return true;
},
{ message: "Song end time must be after start time", path: ["song_end"] }
);
);
export type TeamInput = z.infer<typeof teamInputSchema>;
export type TeamUpdateInput = Partial<TeamInput>;

View File

@@ -1,8 +1,14 @@
import { Autocomplete, Stack, ActionIcon, Text, Group, Loader } from "@mantine/core";
import {
Autocomplete,
Stack,
ActionIcon,
Text,
Group,
Loader,
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useState, useCallback, useMemo, memo } from "react";
import { tournamentQueries } from "../queries";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
import Avatar from "@/components/avatar";
@@ -12,13 +18,17 @@ interface EditEnrolledTeamsProps {
tournamentId: string;
}
const TeamItem = memo(({ team, onUnenroll, disabled }: {
interface TeamItemProps {
team: Team;
onUnenroll: (teamId: string) => void;
disabled: boolean;
}) => {
const playerNames = useMemo(() =>
team.players?.map(p => `${p.first_name} ${p.last_name}`).join(", ") || "",
}
const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
"",
[team.players]
);
@@ -26,9 +36,13 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<Avatar size={32} name={team.name} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>{team.name}</Text>
<Text fw={500} truncate>
{team.name}
</Text>
{playerNames && (
<Text size="xs" c="dimmed" truncate>{playerNames}</Text>
<Text size="xs" c="dimmed" truncate>
{playerNames}
</Text>
)}
</Stack>
<ActionIcon
@@ -48,29 +62,42 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const [search, setSearch] = useState("");
const { data: tournament, isLoading: tournamentLoading } =
useQuery(tournamentQueries.details(tournamentId));
useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useQuery(tournamentQueries.unenrolled(tournamentId));
useUnenrolledTeams(tournamentId);
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const autocompleteData = useMemo(() =>
unenrolledTeams.map((team: Team) => ({ value: team.id, label: team.name })),
const autocompleteData = useMemo(
() =>
unenrolledTeams.map((team: Team) => ({
value: team.id,
label: team.name,
})),
[unenrolledTeams]
);
const handleEnrollTeam = useCallback((teamId: string) => {
enrollTeam({ tournamentId, teamId }, {
const handleEnrollTeam = useCallback(
(teamId: string) => {
enrollTeam(
{ tournamentId, teamId },
{
onSuccess: () => {
setSearch("");
},
}
});
}, [enrollTeam, tournamentId, setSearch]);
);
},
[enrollTeam, tournamentId, setSearch]
);
const handleUnenrollTeam = useCallback((teamId: string) => {
const handleUnenrollTeam = useCallback(
(teamId: string) => {
unenrollTeam({ tournamentId, teamId });
}, [unenrollTeam, tournamentId]);
},
[unenrollTeam, tournamentId]
);
const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || [];
@@ -79,7 +106,9 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">Add Team</Text>
<Text fw={600} size="sm">
Add Team
</Text>
<Autocomplete
placeholder="Search for teams to enroll..."
data={autocompleteData}
@@ -95,14 +124,22 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">Enrolled Teams</Text>
<Text size="xs" c="dimmed">{enrolledTeams.length} teams</Text>
<Text fw={600} size="sm">
Enrolled Teams
</Text>
<Text size="xs" c="dimmed">
{enrolledTeams.length} teams
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md"><Loader size="sm" /></Group>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledTeams ? (
<Text size="sm" c="dimmed" ta="center" py="lg">No teams enrolled yet</Text>
<Text size="sm" c="dimmed" ta="center" py="lg">
No teams enrolled yet
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledTeams.map((team: Team) => (

View File

@@ -1,10 +1,13 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { tournamentQueries } from "../queries";
import { useTournament } from "../queries";
import { List } from "@mantine/core";
import ListButton from "@/components/list-button";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from "@phosphor-icons/react";
import {
HardDrivesIcon,
PencilLineIcon,
UsersThreeIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -13,24 +16,50 @@ interface ManageTournamentProps {
}
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
const { data: tournament } = useSuspenseQuery(
tournamentQueries.details(tournamentId)
);
const { data: tournament } = useTournament(tournamentId);
if (!tournament) throw new Error("Tournament not found.");
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
const { isOpen: editRulesOpened, open: openEditRules, close: closeEditRules } = useSheet();
const { isOpen: editTeamsOpened, open: openEditTeams, close: closeEditTeams } = useSheet();
const {
isOpen: editTournamentOpened,
open: openEditTournament,
close: closeEditTournament,
} = useSheet();
const {
isOpen: editRulesOpened,
open: openEditRules,
close: closeEditRules,
} = useSheet();
const {
isOpen: editTeamsOpened,
open: openEditTeams,
close: closeEditTeams,
} = useSheet();
return (
<>
<List>
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={openEditRules} />
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={openEditTeams} />
<ListButton
label="Edit Tournament"
Icon={HardDrivesIcon}
onClick={openEditTournament}
/>
<ListButton
label="Edit Rules"
Icon={PencilLineIcon}
onClick={openEditRules}
/>
<ListButton
label="Edit Enrolled Teams"
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
</List>
<Sheet title="Edit Tournament" opened={editTournamentOpened} onChange={closeEditTournament}>
<Sheet
title="Edit Tournament"
opened={editTournamentOpened}
onChange={closeEditTournament}
>
<TournamentForm
tournamentId={tournament.id}
initialValues={{
@@ -45,15 +74,23 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
/>
</Sheet>
<Sheet title="Edit Rules" opened={editRulesOpened} onChange={closeEditRules}>
<Sheet
title="Edit Rules"
opened={editRulesOpened}
onChange={closeEditRules}
>
<p>Test</p>
</Sheet>
<Sheet title="Edit Enrolled Teams" opened={editTeamsOpened} onChange={closeEditTeams}>
<Sheet
title="Edit Enrolled Teams"
opened={editTeamsOpened}
onChange={closeEditTeams}
>
<EditEnrolledTeams tournamentId={tournamentId} />
</Sheet>
</>
)
);
};
export default ManageTournament;

View File

@@ -1,15 +1,17 @@
import { Box, Divider, Text } from "@mantine/core";
import { Box, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { Tournament } from "../../types";
import { PreviewBracket } from "@/features/bracket/components/preview";
import { useTournament } from "../../queries";
interface ProfileProps {
tournament: Tournament;
id: string;
}
const Profile = ({ tournament }: ProfileProps) => {
const Profile = ({ id }: ProfileProps) => {
const { data: tournament } = useTournament(id);
if (!tournament) return null;
const tabs = [
{
label: "Overview",

View File

@@ -1,50 +1,73 @@
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
import { Tournament } from "@/features/tournaments/types"
import { useMemo } from "react"
import { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router"
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { useMemo } from "react";
import { CaretRightIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
interface TournamentCardProps {
tournament: Tournament
tournament: Tournament;
}
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate({ from: '/tournaments/$tournamentId' })
const navigate = useNavigate();
const displayDate = useMemo(() => {
if (!tournament.start_time) return null
const date = new Date(tournament.start_time)
if (isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}, [tournament.start_time])
if (!tournament.start_time) return null;
const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}, [tournament.start_time]);
return (
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ cursor: 'pointer' }} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}>
<Card
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{ cursor: "pointer" }}
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
>
<Stack>
<Flex align='center' gap='md'>
<Flex align="center" gap="md">
<Image
src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
maw={100}
mah={100}
fit='contain'
fit="contain"
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
: undefined
}
alt={tournament.name}
fallbackSrc={"TODO"}
/>
<Stack ta='center' mx='auto' gap='0'>
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>
{displayDate && <Text c='dimmed' size='xs' fw={600}>{displayDate}</Text>}
<Stack ta="center" mx="auto" gap="0">
<Text size="lg" fw={800}>
{tournament.name} <CaretRightIcon size={12} weight="bold" />
</Text>
{displayDate && (
<Text c="dimmed" size="xs" fw={600}>
{displayDate}
</Text>
)}
<Stack gap={4} mt={4}>
{ /* TODO: Add medalists when data is available */}
<Badge variant='dot' color='gold'>Longer Team Name Goes Here</Badge>
<Badge variant='dot' color='silver'>Some Team</Badge>
<Badge variant='dot' color='orange'>Medium Team Name</Badge>
{/* TODO: Add medalists when data is available */}
<Badge variant="dot" color="gold">
Longer Team Name Goes Here
</Badge>
<Badge variant="dot" color="silver">
Some Team
</Badge>
<Badge variant="dot" color="orange">
Medium Team Name
</Badge>
</Stack>
</Stack>
</Flex>
</Stack>
</Card>
)
}
);
};

View File

@@ -2,84 +2,97 @@ import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentFormInput } from "@/features/tournaments/types";
import { TournamentInput } from "@/features/tournaments/types";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament";
import useUpdateTournament from "../hooks/use-update-tournament";
import toast from '@/lib/sonner';
import toast from "@/lib/sonner";
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
import { tournamentKeys } from "@/features/tournaments/queries";
import { DateTimePicker } from "@mantine/dates";
import { useCallback } from "react";
interface TournamentFormProps {
close: () => void;
initialValues?: Partial<TournamentFormInput>;
initialValues?: Partial<TournamentInput>;
tournamentId?: string;
}
const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormProps) => {
const TournamentForm = ({
close,
initialValues,
tournamentId,
}: TournamentFormProps) => {
const isEditMode = !!tournamentId;
const config: UseFormInput<TournamentFormInput> = {
const config: UseFormInput<TournamentInput> = {
initialValues: {
name: initialValues?.name || '',
location: initialValues?.location || '',
desc: initialValues?.desc || '',
start_time: initialValues?.start_time || '',
enroll_time: initialValues?.enroll_time || '',
end_time: initialValues?.end_time || '',
name: initialValues?.name || "",
location: initialValues?.location || "",
desc: initialValues?.desc || "",
start_time: initialValues?.start_time || "",
enroll_time: initialValues?.enroll_time || "",
end_time: initialValues?.end_time || "",
logo: undefined,
},
onSubmitPreventDefault: 'always',
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty('Name is required'),
location: isNotEmpty('Location is required'),
start_time: isNotEmpty('Start time is required'),
enroll_time: isNotEmpty('Enrollment time is required'),
}
}
name: isNotEmpty("Name is required"),
location: isNotEmpty("Location is required"),
start_time: isNotEmpty("Start time is required"),
enroll_time: isNotEmpty("Enrollment time is required"),
},
};
const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending: createPending } = useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId || '');
const { mutate: createTournament, isPending: createPending } =
useCreateTournament();
const { mutate: updateTournament, isPending: updatePending } =
useUpdateTournament(tournamentId || "");
const isPending = createPending || updatePending;
const handleSubmit = useCallback(async (values: TournamentFormInput) => {
const handleSubmit = useCallback(
async (values: TournamentInput) => {
const { logo, ...tournamentData } = values;
const mutation = isEditMode ? updateTournament : createTournament;
const successMessage = isEditMode ? 'Tournament updated successfully!' : 'Tournament created successfully!';
const errorMessage = isEditMode ? 'Failed to update tournament' : 'Failed to create tournament';
const successMessage = isEditMode
? "Tournament updated successfully!"
: "Tournament created successfully!";
const errorMessage = isEditMode
? "Failed to update tournament"
: "Failed to create tournament";
mutation(tournamentData, {
onSuccess: async (tournament) => {
if (logo && tournament) {
try {
const formData = new FormData();
formData.append('tournamentId', tournament.id);
formData.append('logo', logo);
formData.append("tournamentId", tournament.id);
formData.append("logo", logo);
const response = await fetch('/api/tournaments/upload-logo', {
method: 'POST',
const response = await fetch("/api/tournaments/upload-logo", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
throw new Error(error.error || "Failed to upload logo");
}
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(result.tournament!.id),
});
queryClient.setQueryData(
tournamentQueries.details(result.tournament!.id).queryKey,
tournamentKeys.details(result.tournament!.id),
result.tournament
);
@@ -89,7 +102,7 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
? `Tournament updated but logo upload failed: ${error.message}`
: `Tournament created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error('Tournament logo upload error', error);
logger.error("Tournament logo upload error", error);
}
} else {
toast.success(successMessage);
@@ -98,10 +111,15 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
},
onError: (error: any) => {
toast.error(`${errorMessage}: ${error.message}`);
logger.error(`Tournament ${isEditMode ? 'update' : 'create'} error`, error);
}
logger.error(
`Tournament ${isEditMode ? "update" : "create"} error`,
error
);
},
});
}, [isEditMode, createTournament, updateTournament, queryClient]);
},
[isEditMode, createTournament, updateTournament, queryClient]
);
return (
<SlidePanel
@@ -115,83 +133,91 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
key={form.key("name")}
{...form.getInputProps("name")}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
key={form.key("location")}
{...form.getInputProps("location")}
/>
<Textarea
label="Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
key={form.key("desc")}
{...form.getInputProps("desc")}
minRows={3}
/>
<FileInput
key={form.key('logo')}
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo')}
{...form.getInputProps("logo")}
/>
<SlidePanelField
key={form.key('start_time')}
{...form.getInputProps('start_time')}
key={form.key("start_time")}
{...form.getInputProps("start_time")}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
formatValue={(date) =>
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
<SlidePanelField
key={form.key('enroll_time')}
{...form.getInputProps('enroll_time')}
key={form.key("enroll_time")}
{...form.getInputProps("enroll_time")}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
formatValue={(date) =>
new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}
/>
{isEditMode && (
<SlidePanelField
key={form.key('end_time')}
{...form.getInputProps('end_time')}
key={form.key("end_time")}
{...form.getInputProps("end_time")}
Component={DateTimePicker}
title="Select End Date"
label="End Date (Optional)"
formatValue={(date) => date ? new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}) : 'Not set'}
formatValue={(date) =>
date
? new Date(date).toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
})
: "Not set"
}
/>
)}
</Stack>

View File

@@ -1,35 +1,21 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { createTournament } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { TournamentInput } from "@/features/tournaments/types";
import { logger } from "../";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateTournament = () => {
const navigate = useNavigate();
return useMutation({
return useServerMutation({
mutationFn: (data: TournamentInput) => createTournament({ data }),
onMutate: (data) => {
logger.info('Creating tournament', data);
},
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your tournament. Please try again later.');
logger.error('Error creating tournament', data);
} else {
logger.info('Tournament created successfully', data);
onSuccess: () => {
navigate({ to: '/tournaments' });
}
},
onError: (error: any) => {
logger.error('Error creating tournament', error);
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to create a tournament. Please try again later.');
}
},
successMessage: 'Tournament created successfully!',
});
};

View File

@@ -1,31 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { enrollTeam } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useEnrollTeam = () => {
const queryClient = useQueryClient();
return useMutation({
return useServerMutation({
mutationFn: (data: { tournamentId: string, teamId: string }) => {
return enrollTeam({ data });
},
onSuccess: (data, { tournamentId }) => {
if (!data) {
toast.error('There was an issue enrolling. Please try again later.');
} else {
// Invalidate both tournament details and unenrolled teams queries
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
toast.success('Team enrolled successfully!');
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to enroll the team. Please try again later.');
}
},
successMessage: 'Team enrolled successfully!',
});
};

View File

@@ -1,31 +1,19 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { unenrollTeam } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useUnenrollTeam = () => {
const queryClient = useQueryClient();
return useMutation({
return useServerMutation({
mutationFn: (data: { tournamentId: string, teamId: string }) => {
return unenrollTeam({ data });
},
onSuccess: (data, { tournamentId }) => {
if (!data) {
toast.error('There was an issue unenrolling. Please try again later.');
} else {
// Invalidate both tournament details and unenrolled teams queries
onSuccess: (_, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
toast.success('Team unenrolled successfully.');
}
},
onError: (error: any) => {
if (error.message) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to unenroll the team. Please try again later.');
}
},
successMessage: 'Team unenrolled successfully.',
});
};

View File

@@ -1,10 +1,10 @@
import { useMutation } from "@tanstack/react-query";
import { updateTournament } from "@/features/tournaments/server";
import { TournamentFormInput } from "@/features/tournaments/types";
import { TournamentInput } from "@/features/tournaments/types";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useUpdateTournament = (tournamentId: string) => {
return useMutation({
mutationFn: (data: Partial<TournamentFormInput>) =>
return useServerMutation({
mutationFn: (data: Partial<TournamentInput>) =>
updateTournament({ data: { id: tournamentId, updates: data } }),
});
};

View File

@@ -1,23 +1,32 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
const tournamentKeys = {
export const tournamentKeys = {
list: ['tournaments', 'list'] as const,
details: (id: string) => ['tournaments', 'details', id] as const,
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
};
export const tournamentQueries = {
list: () => queryOptions({
list: () => ({
queryKey: tournamentKeys.list,
queryFn: listTournaments
}),
details: (id: string) => queryOptions({
details: (id: string) => ({
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id })
}),
unenrolled: (id: string) => queryOptions({
unenrolled: (id: string) => ({
queryKey: tournamentKeys.unenrolled(id),
queryFn: () => getUnenrolledTeams({ data: id })
})
};
export const useTournaments = () =>
useServerSuspenseQuery(tournamentQueries.list());
export const useTournament = (id: string) =>
useServerSuspenseQuery(tournamentQueries.details(id));
export const useUnenrolledTeams = (tournamentId: string) =>
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));

View File

@@ -4,34 +4,20 @@ import { pbAdmin } from "@/lib/pocketbase/client";
import { tournamentInputSchema } from "@/features/tournaments/types";
import { logger } from ".";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
const result = await pbAdmin.listTournaments();
return result;
} catch (error) {
logger.error('Error fetching tournaments', error);
return [];
}
});
.handler(async () =>
toServerResult(pbAdmin.listTournaments)
);
export const createTournament = createServerFn()
.validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Creating tournament', data);
const tournament = await pbAdmin.createTournament(data);
return tournament;
} catch (error) {
logger.error('Error creating tournament', error);
return null;
}
});
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data))
);
export const updateTournament = createServerFn()
.validator(z.object({
@@ -39,26 +25,16 @@ export const updateTournament = createServerFn()
updates: tournamentInputSchema.partial()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Updating tournament', data);
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
return tournament;
} catch (error) {
logger.error('Error updating tournament', error);
return null;
}
});
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
);
export const getTournament = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting tournament', tournamentId);
const tournament = await pbAdmin.getTournament(tournamentId);
return tournament;
});
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getTournament(tournamentId))
);
export const enrollTeam = createServerFn()
.validator(z.object({
@@ -66,8 +42,8 @@ export const enrollTeam = createServerFn()
teamId: z.string()
}))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => {
try {
.handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(async () => {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
@@ -83,11 +59,8 @@ export const enrollTeam = createServerFn()
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
return tournament;
} catch (error) {
logger.error('Error enrolling team', error);
throw error;
}
});
})
);
export const unenrollTeam = createServerFn()
.validator(z.object({
@@ -95,22 +68,13 @@ export const unenrollTeam = createServerFn()
teamId: z.string()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, teamId }, context }) => {
try {
logger.info('Enrolling team in tournament', { tournamentId, teamId, context });
const tournament = await pbAdmin.unenrollTeam(tournamentId, teamId);
return tournament;
} catch (error) {
logger.error('Error enrolling team', error);
throw error;
}
});
.handler(async ({ data: { tournamentId, teamId }, context }) =>
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
);
export const getUnenrolledTeams = createServerFn()
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting unenrolled teams', tournamentId);
const teams = await pbAdmin.getUnenrolledTeams(tournamentId);
return teams;
});
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
);

View File

@@ -16,19 +16,6 @@ export interface Tournament {
teams?: Team[];
}
// Schema for the form (client-side)
export const tournamentFormSchema = z.object({
name: z.string(),
location: z.string().optional(),
desc: z.string().optional(),
rules: z.string().optional(),
logo: z.file().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
// Schema for the server input (with base64 logo)
export const tournamentInputSchema = z.object({
name: z.string(),
location: z.string().optional(),
@@ -40,6 +27,5 @@ export const tournamentInputSchema = z.object({
end_time: z.string().optional(),
});
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
export type TournamentUpdateInput = Partial<TournamentInput>;

View File

@@ -7,15 +7,15 @@ const useAppShellHeight = () => {
const { header } = useRouterConfig();
const height = useMemo(() => {
const appShellBottomPadding = isMobile ? '70px' : '0px';
const pageBottomPadding = '20px';
const mobileNavbar = isMobile && !header.collapsed ? '4rem' : '0px';
const pullablePadding = '1.285rem';
const appShellBottomPadding = isMobile ? "70px" : "0px";
const pageBottomPadding = "20px";
const mobileNavbar = isMobile && !header.collapsed ? "4rem" : "0px";
const pullablePadding = "1.285rem";
return `calc(100dvh - var(--app-shell-header-height, 0px) - ${mobileNavbar} - ${pullablePadding} - ${appShellBottomPadding} - ${pageBottomPadding})`;
}, [isMobile, header.collapsed]);
return height;
}
};
export default useAppShellHeight;

View File

@@ -1,6 +1,6 @@
import { useMediaQuery } from "@mantine/hooks";
export const useIsMobile = () => {
const isMobile = useMediaQuery('(max-width: 48em)');
const isMobile = useMediaQuery("(max-width: 48em)");
return isMobile;
}
};

View File

@@ -9,7 +9,7 @@ export function useSheet(options: UseSheetOptions = {}) {
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
return {
isOpen,
@@ -19,6 +19,6 @@ export function useSheet(options: UseSheetOptions = {}) {
props: {
opened: isOpen,
onChange: setIsOpen,
}
},
};
}

View File

@@ -1,4 +1,4 @@
type LogLevel = 'info' | 'success' | 'warn' | 'error';
type LogLevel = "info" | "success" | "warn" | "error";
interface LoggerOptions {
enabled?: boolean;
@@ -7,25 +7,19 @@ interface LoggerOptions {
colors?: boolean;
}
// Cache for performance - update once per second max
let cachedTimestamp = '';
let cachedTimestamp = "";
let lastTimestampUpdate = 0;
/**
* Get formatted timestamp with caching for performance
* Format: MM/DD HH:mm:ss
*/
function getTimestamp(): string {
const now = Date.now();
// Update cache only if more than 1 second has passed
if (now - lastTimestampUpdate > 1000) {
const date = new Date(now);
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
lastTimestampUpdate = now;
@@ -34,23 +28,17 @@ function getTimestamp(): string {
return cachedTimestamp;
}
/**
* Get color and emoji for each log level
*/
function getLevelStyle(level: LogLevel): { color: string; label: string } {
const styles = {
info: { color: '#f5f5f5', label: 'INFO' },
success: { color: '#10B981', label: 'SUCCESS' },
warn: { color: '#F59E0B', label: 'WARN' },
error: { color: '#EF4444', label: 'ERROR' },
info: { color: "#f5f5f5", label: "INFO" },
success: { color: "#10B981", label: "SUCCESS" },
warn: { color: "#F59E0B", label: "WARN" },
error: { color: "#EF4444", label: "ERROR" },
};
return styles[level] || styles.info;
}
/**
* Main logger class
*/
class Logger {
private options: LoggerOptions;
private context?: string;
@@ -58,25 +46,21 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) {
this.context = context;
this.options = {
enabled: process.env.NODE_ENV !== 'production',
enabled: process.env.NODE_ENV !== "production",
showTimestamp: true,
collapsed: true,
colors: true,
...options
...options,
};
}
/**
* Create a child logger with a specific context
*/
child(context: string, options?: LoggerOptions): Logger {
const childContext = this.context ? `${this.context} > ${context}` : context;
const childContext = this.context
? `${this.context} > ${context}`
: context;
return new Logger(childContext, { ...this.options, ...options });
}
/**
* Core logging method
*/
private log(
level: LogLevel,
label: string,
@@ -86,18 +70,17 @@ class Logger {
if (!this.options.enabled) return;
const style = getLevelStyle(level);
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : "";
const context = this.context ? `${this.context}` : "";
const groupLabel = `${timestamp}${style.label}${context}${label}`;
const group = this.options.collapsed ? console.groupCollapsed : console.group;
const group = this.options.collapsed
? console.groupCollapsed
: console.group;
if (this.options.colors && typeof window !== 'undefined') {
group(
`%c${groupLabel}`,
`color: ${style.color}; font-weight: bold;`
);
if (this.options.colors && typeof window !== "undefined") {
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
} else {
group(groupLabel);
}
@@ -115,44 +98,38 @@ class Logger {
console.groupEnd();
}
/**
* log level methods
*/
info(label: string, data?: any, ...rest: any[]): void {
this.log('info', label, data, ...rest);
this.log("info", label, data, ...rest);
}
success(label: string, data?: any, ...rest: any[]): void {
this.log('success', label, data, ...rest);
this.log("success", label, data, ...rest);
}
warn(label: string, data?: any, ...rest: any[]): void {
this.log('warn', label, data, ...rest);
this.log("warn", label, data, ...rest);
}
error(label: string, data?: any, ...rest: any[]): void {
this.log('error', label, data, ...rest);
this.log("error", label, data, ...rest);
}
simple(message: string): void {
if (!this.options.enabled) return;
const style = getLevelStyle('info');
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
const style = getLevelStyle("info");
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : "";
const context = this.context ? `${this.context}` : "";
const logMessage = `${timestamp}${style.label}${context}${message}`;
if (this.options.colors && typeof window !== 'undefined') {
if (this.options.colors && typeof window !== "undefined") {
console.log(`%c${logMessage}`, `color: ${style.color};`);
} else {
console.log(logMessage);
}
}
/**
* Measure performance of an operation
*/
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
const start = performance.now();
@@ -162,7 +139,7 @@ class Logger {
this.success(`${label} completed`, {
duration: `${duration}ms`,
result
result,
});
return result;
@@ -171,29 +148,14 @@ class Logger {
this.error(`${label} failed`, {
duration: `${duration}ms`,
error
error,
});
throw error;
}
}
/**
* Create a table log
*/
table(label: string, data: any[]): void {
if (!this.options.enabled) return;
const timestamp = this.options.showTimestamp ? `${getTimestamp()}` : '';
const context = this.context ? `${this.context}` : '';
console.group(`${timestamp}TABLE${context}${label}`);
console.table(data);
console.groupEnd();
}
}
export const logger = new Logger();
export { Logger };

View File

@@ -4,7 +4,9 @@ import { useMantineColorScheme } from "@mantine/core";
import { useEffect } from "react";
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata: { colorScheme } } = useAuth()
const {
metadata: { colorScheme },
} = useAuth();
const { setColorScheme } = useMantineColorScheme();
const theme = useMantineTheme();
@@ -14,10 +16,13 @@ const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
setColorScheme(colorScheme);
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute('content', colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0]);
themeColorMeta.setAttribute(
"content",
colorScheme === "dark" ? theme.colors.dark[8] : theme.colors.gray[0]
);
}
}, [colorScheme]);
return children
}
return children;
};
export default ColorSchemeProvider;

View File

@@ -1,68 +1,71 @@
import { useAuth } from "@/contexts/auth-context";
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
import {
createTheme,
MantineProvider as MantineProviderCore,
} from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider";
import { useState, useEffect } from "react";
const commonInputStyles = {
label: {
padding: 5
padding: 5,
},
root: {
margin: '0'
}
}
margin: "0",
},
};
const theme = createTheme({
defaultRadius: 'sm',
defaultRadius: "sm",
components: {
TextInput: {
styles: commonInputStyles
styles: commonInputStyles,
},
DateTimePicker: {
styles: commonInputStyles
styles: commonInputStyles,
},
Input: {
styles: commonInputStyles
styles: commonInputStyles,
},
Select: {
styles: commonInputStyles
styles: commonInputStyles,
},
Autocomplete: {
styles: commonInputStyles
styles: commonInputStyles,
},
DateTiemPicker: {
styles: {
root: {
zIndex: 1000
zIndex: 1000,
},
input: {
zIndex: 1000,
backgroundColor: 'red'
}
}
}
backgroundColor: "red",
},
},
},
},
});
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata } = useAuth()
const [isHydrated, setIsHydrated] = useState(false)
const { metadata } = useAuth();
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true)
}, [])
setIsHydrated(true);
}, []);
const colorScheme = isHydrated ? (metadata.colorScheme || 'auto') : 'auto'
const primaryColor = isHydrated ? (metadata.accentColor || 'blue') : 'blue'
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
return <MantineProviderCore
return (
<MantineProviderCore
defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }}
>
<ColorSchemeProvider>
{children}
</ColorSchemeProvider>
<ColorSchemeProvider>{children}</ColorSchemeProvider>
</MantineProviderCore>
}
);
};
export default MantineProvider;

View File

@@ -1,7 +1,7 @@
import PocketBase from 'pocketbase';
import { createPlayersService } from './services/players';
import { createTournamentsService } from './services/tournaments';
import { createTeamsService } from './services/teams';
import PocketBase from "pocketbase";
import { createPlayersService } from "./services/players";
import { createTournamentsService } from "./services/tournaments";
import { createTeamsService } from "./services/teams";
class PocketBaseAdminClient {
private pb: PocketBase;
@@ -11,12 +11,12 @@ class PocketBaseAdminClient {
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
this.pb.beforeSend = (url, options) => {
options.cache = 'no-store';
options.cache = "no-store";
options.headers = {
...options.headers,
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
};
return { url, options };
@@ -33,15 +33,17 @@ class PocketBaseAdminClient {
}
private async authenticate() {
await this.pb.collection("_superusers").authWithPassword(
await this.pb
.collection("_superusers")
.authWithPassword(
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
);
}
}
interface AdminClient extends
PocketBaseAdminClient,
interface AdminClient
extends PocketBaseAdminClient,
ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService> {

View File

@@ -1,46 +1,50 @@
import type { Player, PlayerInput, PlayerUpdateInput } from '@/features/players/types';
import { transformPlayer } from '@/lib/pocketbase/util/transform-types';
import PocketBase from 'pocketbase';
import type {
Player,
PlayerInput,
PlayerUpdateInput,
} from "@/features/players/types";
import { transformPlayer } from "@/lib/pocketbase/util/transform-types";
import PocketBase from "pocketbase";
export function createPlayersService(pb: PocketBase) {
return {
async getPlayerByAuthId(authId: string): Promise<Player | null> {
const result = await pb.collection('players').getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`
const result = await pb.collection("players").getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`,
});
return result.items[0] ? transformPlayer(result.items[0]) : null;
},
async getPlayer(id: string): Promise<Player | null> {
const result = await pb.collection('players').getOne(id, {
expand: 'teams'
async getPlayer(id: string): Promise<Player> {
const result = await pb.collection("players").getOne(id, {
expand: "teams",
});
return transformPlayer(result);
},
async listPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
fields: 'id,first_name,last_name'
const result = await pb.collection("players").getFullList<Player>({
fields: "id,first_name,last_name",
});
return result.map(transformPlayer);
},
async createPlayer(data: PlayerInput): Promise<Player> {
const result = await pb.collection('players').create<Player>(data);
const result = await pb.collection("players").create<Player>(data);
return transformPlayer(result);
},
async updatePlayer(id: string, data: PlayerUpdateInput): Promise<Player> {
const result = await pb.collection('players').update<Player>(id, data);
const result = await pb.collection("players").update<Player>(id, data);
return transformPlayer(result);
},
async getUnassociatedPlayers(): Promise<Player[]> {
const result = await pb.collection('players').getFullList<Player>({
const result = await pb.collection("players").getFullList<Player>({
filter: 'auth_id = ""',
fields: 'id,first_name,last_name'
fields: "id,first_name,last_name",
});
return result.map(transformPlayer);
}
},
};
}

View File

@@ -6,15 +6,11 @@ import { Team } from "@/features/teams/types";
export function createTeamsService(pb: PocketBase) {
return {
async getTeam(id: string): Promise<Team | null> {
try {
logger.info('PocketBase | Getting team', id);
const result = await pb.collection('teams').getOne(id, {
expand: 'players, tournaments'
logger.info("PocketBase | Getting team", id);
const result = await pb.collection("teams").getOne(id, {
expand: "players, tournaments",
});
return transformTeam(result);
} catch {
return null;
}
},
};
}

View File

@@ -12,15 +12,11 @@ import { transformTeam } from "@/lib/pocketbase/util/transform-types";
export function createTournamentsService(pb: PocketBase) {
return {
async getTournament(id: string): Promise<Tournament | null> {
try {
logger.info("PocketBase | Getting tournament", id);
const result = await pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players",
});
return transformTournament(result);
} catch {
return null;
}
},
async listTournaments(): Promise<Tournament[]> {
const result = await pb
@@ -51,16 +47,17 @@ export function createTournamentsService(pb: PocketBase) {
tournamentId: string,
teamId: string
): Promise<Tournament> {
const result = await pb.collection("tournaments").update<Tournament>(
const result = await pb
.collection("tournaments")
.update<Tournament>(
tournamentId,
{ "teams+": teamId },
{ expand: "teams, teams.players" }
);
await pb.collection("teams").update(
teamId,
{ "tournaments+": tournamentId }
);
await pb
.collection("teams")
.update(teamId, { "tournaments+": tournamentId });
return transformTournament(result);
},
@@ -68,30 +65,36 @@ export function createTournamentsService(pb: PocketBase) {
tournamentId: string,
teamId: string
): Promise<Tournament> {
const result = await pb.collection("tournaments").update<Tournament>(
const result = await pb
.collection("tournaments")
.update<Tournament>(
tournamentId,
{ "teams-": teamId },
{ expand: "teams, teams.players" }
);
await pb.collection("teams").update(
teamId,
{ "tournaments-": tournamentId }
);
await pb
.collection("teams")
.update(teamId, { "tournaments-": tournamentId });
return transformTournament(result);
},
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
try {
logger.info("PocketBase | Getting unenrolled teams for tournament", tournamentId);
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
fields: "teams"
logger.info(
"PocketBase | Getting unenrolled teams for tournament",
tournamentId
);
const tournament = await pb
.collection("tournaments")
.getOne(tournamentId, {
fields: "teams",
});
const enrolledTeamIds = tournament.teams || [];
if (enrolledTeamIds.length === 0) {
const allTeams = await pb.collection("teams").getFullList({
expand: "players"
expand: "players",
});
return allTeams.map(transformTeam);
}
@@ -102,7 +105,7 @@ export function createTournamentsService(pb: PocketBase) {
const availableTeams = await pb.collection("teams").getFullList({
filter,
expand: "players"
expand: "players",
});
return availableTeams.map(transformTeam);

View File

@@ -56,7 +56,7 @@ export function transformTeam(record: any): Team {
created: record.created,
updated: record.updated,
players,
tournaments
tournaments,
};
}

View File

@@ -1,9 +1,9 @@
import { toast as sonnerToast } from 'sonner';
import { ToastProps } from './types';
import { Notification } from '@mantine/core';
import { ShieldCheckIcon, WarningCircleIcon } from '@phosphor-icons/react';
import { toast as sonnerToast } from "sonner";
import { ToastProps } from "./types";
import { Notification } from "@mantine/core";
import { ShieldCheckIcon, WarningCircleIcon } from "@phosphor-icons/react";
const makeToast = (toast: Omit<ToastProps, 'id'>) => {
const makeToast = (toast: Omit<ToastProps, "id">) => {
return sonnerToast.custom((id) => (
<Toast
id={id}
@@ -14,24 +14,33 @@ const makeToast = (toast: Omit<ToastProps, 'id'>) => {
color={toast.color}
loading={!!toast.loading}
/>
))
));
};
function success(toast: Omit<ToastProps, "id"> | string) {
const config = typeof toast === "string" ? { description: toast } : toast;
return makeToast({
...config,
icon: <ShieldCheckIcon color="lightgreen" size={48} weight="fill" />,
});
}
function success(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <ShieldCheckIcon color='lightgreen' size={48} weight='fill'/> });
}
function error(toast: Omit<ToastProps, 'id'> | string) {
const config = typeof toast === 'string' ? { description: toast } : toast;
return makeToast({ ...config, icon: <WarningCircleIcon color='lightcoral' size={48} weight='fill' /> });
function error(toast: Omit<ToastProps, "id"> | string) {
const config = typeof toast === "string" ? { description: toast } : toast;
return makeToast({
...config,
icon: <WarningCircleIcon color="lightcoral" size={48} weight="fill" />,
});
}
function Toast(props: ToastProps) {
const { title, description, withCloseButton, icon, loading } = props;
return (
<Notification miw='md' color={'rgba(0,0,0,0)'} withBorder
<Notification
miw="md"
color={"rgba(0,0,0,0)"}
withBorder
withCloseButton={!!withCloseButton}
loading={loading}
title={title}
@@ -45,4 +54,4 @@ function Toast(props: ToastProps) {
export default {
success,
error,
}
};

View File

@@ -1,8 +1,8 @@
import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
import { appInfo } from './config';
import { logger } from './';
import SuperTokens from "supertokens-web-js";
import Session from "supertokens-web-js/recipe/session";
import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config";
import { logger } from "./";
export const frontendConfig = () => {
return {
@@ -17,22 +17,22 @@ export const frontendConfig = () => {
context.requestInit.credentials = "include";
return context;
},
})
]
}),
],
};
}
};
let initialized = false;
export function ensureSuperTokensFrontend() {
if (typeof window === 'undefined') return;
if (typeof window === "undefined") return;
if (!initialized) {
SuperTokens.init(frontendConfig());
initialized = true;
logger.info("Initialized");
Session.doesSessionExist().then(exists => {
logger.info(`Session does${exists ? '' : 'NOT'} exist on load!`);
Session.doesSessionExist().then((exists) => {
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
});
}
}

View File

@@ -1,7 +1,7 @@
import Passwordless from "supertokens-node/recipe/passwordless";
import { logger } from "../";
const init = () => (
const init = () =>
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
@@ -14,17 +14,17 @@ const init = () => (
throw new Error("No user input code provided to sendSms");
}
logger.info('Sending Code',
'######################',
'## SuperTokens Code ##',
logger.info(
"Sending Code",
"######################",
"## SuperTokens Code ##",
`## ${userInputCode} ##`,
'######################'
"######################"
);
}
}
}
}
})
)
},
};
},
},
});
export default { init };

View File

@@ -2,7 +2,7 @@ import { useSession } from "@tanstack/react-start/server";
import Passwordless from "supertokens-node/recipe/passwordless";
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
const init = () => (
const init = () =>
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
@@ -18,22 +18,24 @@ const init = () => (
const sid = await sendVerifyCode(phoneNumber, userInputCode);
const session = await useSession({
password: preAuthSessionId
password: preAuthSessionId,
});
await session.update({
twilioSid: sid
twilioSid: sid,
});
}
}
}
},
};
},
},
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
consumeCode: async (input) => {
const session = await useSession({ password: input.preAuthSessionId });
const session = await useSession({
password: input.preAuthSessionId,
});
const twilioSid = session?.data.twilioSid;
if (!twilioSid) {
@@ -46,20 +48,22 @@ const init = () => (
await updateVerify(twilioSid);
await session.update({
twilioSid: undefined,
userId: response?.user.id
})
userId: response?.user.id,
});
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
if (response.failedCodeInputAttemptCount !== response.maximumCodeInputAttempts) {
if (
response.failedCodeInputAttemptCount !==
response.maximumCodeInputAttempts
) {
await updateVerify(twilioSid);
}
}
return response;
}
}
}
}
})
)
},
};
},
},
});
export default { init };

View File

@@ -9,6 +9,14 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
const session = await getSessionForSSR(request);
if (session.hasToken) {
if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) {
return {
hasToken: true,
needsRefresh: true,
error: 'TRY_REFRESH_TOKEN'
}
}
return {
hasToken: true,
accessTokenPayload: session.accessTokenPayload,
@@ -36,13 +44,3 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
throw error;
}
}
export async function verifySession(request: Request, options?: { sessionRequired?: boolean }) {
const session = await getSessionForStart(request, options);
if (!session && options?.sessionRequired !== false) {
throw new Response("Unauthorized", { status: 401 });
}
return session;
}

View File

@@ -9,28 +9,30 @@ import { logger } from "./";
export const backendConfig = (): TypeInput => {
return {
framework: 'custom',
framework: "custom",
supertokens: {
connectionURI: import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
connectionURI:
import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
},
appInfo,
recipeList: [
PasswordlessDevelopmentMode.init(),
Session.init({
cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === 'production',
cookieDomain: process.env.NODE_ENV === 'production' ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === 'production' ? "VIA_TOKEN" : "NONE",
cookieSecure: process.env.NODE_ENV === "production",
cookieDomain:
process.env.NODE_ENV === "production" ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: true,
}),
Dashboard.init(),
UserRoles.init()
UserRoles.init(),
],
telemetry: process.env.NODE_ENV !== 'production',
telemetry: process.env.NODE_ENV !== "production",
};
}
};
let initialized = false;
export function ensureSuperTokensBackend() {

View File

@@ -0,0 +1,4 @@
export * from './use-optimistic-mutation';
export * from './use-server-mutation';
export * from './use-server-query';
export * from './user-server-suspense-query';

View File

@@ -0,0 +1,37 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "./use-server-mutation";
export function useOptimisticMutation<TData, TVariables = unknown>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
queryKey: readonly (string | number)[];
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
}
) {
const queryClient = useQueryClient();
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
return useServerMutation({
...mutationOptions,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (optimisticUpdate && previousData) {
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
}
return { previousData };
},
onError: (error, variables, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
mutationOptions.onError?.(error, variables, context);
},
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context);
}
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
successMessage?: string;
showErrorToast?: boolean;
showSuccessToast?: boolean;
}
) {
const {
mutationFn,
successMessage,
showErrorToast = true,
showSuccessToast = true,
onSuccess,
onError,
...mutationOptions
} = options;
return useMutation({
...mutationOptions,
mutationFn: async (variables: TVariables) => {
const result = await mutationFn(variables);
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
},
onSuccess: (data, variables, context) => {
if (showSuccessToast && successMessage) {
toast.success(successMessage);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
onError?.(error, variables, context);
}
});
}

View File

@@ -0,0 +1,31 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerQuery<TData>(
options: {
queryKey: QueryKey,
queryFn: () => Promise<ServerResult<TData>>;
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
showErrorToast?: boolean;
}
) {
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
return useQuery({
...queryOptions,
queryKey,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}

View File

@@ -1,132 +0,0 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ServerResult } from '@/lib/tanstack-query/types';
export function useServerQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerSuspenseQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useSuspenseQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
successMessage?: string;
showErrorToast?: boolean;
showSuccessToast?: boolean;
}
) {
const {
mutationFn,
successMessage,
showErrorToast = true,
showSuccessToast = true,
onSuccess,
onError,
...mutationOptions
} = options;
return useMutation({
...mutationOptions,
mutationFn: async (variables: TVariables) => {
const result = await mutationFn(variables);
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
},
onSuccess: (data, variables, context) => {
if (showSuccessToast && successMessage) {
toast.success(successMessage);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
onError?.(error, variables, context);
}
});
}
export function useOptimisticMutation<TData, TVariables = unknown>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
queryKey: readonly (string | number)[];
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
}
) {
const queryClient = useQueryClient();
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
return useServerMutation({
...mutationOptions,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (optimisticUpdate && previousData) {
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
}
return { previousData };
},
onError: (error, variables, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
mutationOptions.onError?.(error, variables, context);
},
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context);
}
});
}

View File

@@ -0,0 +1,32 @@
import { QueryKey, UseQueryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { ServerResult } from "../types";
import toast from '@/lib/sonner'
export function useServerSuspenseQuery<TData>(
options: {
queryKey: QueryKey,
queryFn: () => Promise<ServerResult<TData>>;
options?: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn' | 'queryKey'>
showErrorToast?: boolean;
}
) {
const { queryKey, queryFn, showErrorToast = true, options: queryOptions } = options;
const queryResult = useSuspenseQuery({
...queryOptions,
queryKey,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
return queryResult;
}

Some files were not shown because too many files have changed in this diff Show More