significant refactor
This commit is contained in:
@@ -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 ?? "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,10 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "vite start",
|
"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"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -28,8 +25,6 @@
|
|||||||
"@tanstack/react-router-with-query": "^1.130.12",
|
"@tanstack/react-router-with-query": "^1.130.12",
|
||||||
"@tanstack/react-start": "^1.130.15",
|
"@tanstack/react-start": "^1.130.15",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
"drizzle-orm": "^0.44.4",
|
|
||||||
"drizzle-zod": "^0.8.3",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
@@ -57,7 +52,6 @@
|
|||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^10.0.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.1",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
|
|||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
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 ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
||||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
||||||
@@ -105,11 +104,6 @@ const AuthedAdminTournamentsIdRoute =
|
|||||||
path: '/tournaments/$id',
|
path: '/tournaments/$id',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const ApiTestServerRoute = ApiTestServerRouteImport.update({
|
|
||||||
id: '/api/test',
|
|
||||||
path: '/api/test',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiTournamentsUploadLogoServerRoute =
|
const ApiTournamentsUploadLogoServerRoute =
|
||||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
ApiTournamentsUploadLogoServerRouteImport.update({
|
||||||
id: '/api/tournaments/upload-logo',
|
id: '/api/tournaments/upload-logo',
|
||||||
@@ -233,14 +227,12 @@ export interface RootRouteChildren {
|
|||||||
LogoutRoute: typeof LogoutRoute
|
LogoutRoute: typeof LogoutRoute
|
||||||
}
|
}
|
||||||
export interface FileServerRoutesByFullPath {
|
export interface FileServerRoutesByFullPath {
|
||||||
'/api/test': typeof ApiTestServerRoute
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||||
}
|
}
|
||||||
export interface FileServerRoutesByTo {
|
export interface FileServerRoutesByTo {
|
||||||
'/api/test': typeof ApiTestServerRoute
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -248,7 +240,6 @@ export interface FileServerRoutesByTo {
|
|||||||
}
|
}
|
||||||
export interface FileServerRoutesById {
|
export interface FileServerRoutesById {
|
||||||
__root__: typeof rootServerRouteImport
|
__root__: typeof rootServerRouteImport
|
||||||
'/api/test': typeof ApiTestServerRoute
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -257,21 +248,18 @@ export interface FileServerRoutesById {
|
|||||||
export interface FileServerRouteTypes {
|
export interface FileServerRouteTypes {
|
||||||
fileServerRoutesByFullPath: FileServerRoutesByFullPath
|
fileServerRoutesByFullPath: FileServerRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/api/test'
|
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
fileServerRoutesByTo: FileServerRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/api/test'
|
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
| '/api/files/$collection/$recordId/$file'
|
| '/api/files/$collection/$recordId/$file'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/api/test'
|
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/events/$'
|
| '/api/events/$'
|
||||||
| '/api/tournaments/upload-logo'
|
| '/api/tournaments/upload-logo'
|
||||||
@@ -279,7 +267,6 @@ export interface FileServerRouteTypes {
|
|||||||
fileServerRoutesById: FileServerRoutesById
|
fileServerRoutesById: FileServerRoutesById
|
||||||
}
|
}
|
||||||
export interface RootServerRouteChildren {
|
export interface RootServerRouteChildren {
|
||||||
ApiTestServerRoute: typeof ApiTestServerRoute
|
|
||||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||||
@@ -390,13 +377,6 @@ declare module '@tanstack/react-router' {
|
|||||||
}
|
}
|
||||||
declare module '@tanstack/react-start/server' {
|
declare module '@tanstack/react-start/server' {
|
||||||
interface ServerFileRoutesByPath {
|
interface ServerFileRoutesByPath {
|
||||||
'/api/test': {
|
|
||||||
id: '/api/test'
|
|
||||||
path: '/api/test'
|
|
||||||
fullPath: '/api/test'
|
|
||||||
preLoaderRoute: typeof ApiTestServerRouteImport
|
|
||||||
parentRoute: typeof rootServerRouteImport
|
|
||||||
}
|
|
||||||
'/api/tournaments/upload-logo': {
|
'/api/tournaments/upload-logo': {
|
||||||
id: '/api/tournaments/upload-logo'
|
id: '/api/tournaments/upload-logo'
|
||||||
path: '/api/tournaments/upload-logo'
|
path: '/api/tournaments/upload-logo'
|
||||||
@@ -478,7 +458,6 @@ export const routeTree = rootRouteImport
|
|||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
const rootServerRouteChildren: RootServerRouteChildren = {
|
const rootServerRouteChildren: RootServerRouteChildren = {
|
||||||
ApiTestServerRoute: ApiTestServerRoute,
|
|
||||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
|
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from '../components/DefaultCatchBoundary'
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from '@/features/core/hooks/use-router-config'
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function createRouter() {
|
export function createRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -12,27 +12,33 @@ export function createRouter() {
|
|||||||
staleTime: 60 * 1000, // 60 seconds
|
staleTime: 60 * 1000, // 60 seconds
|
||||||
gcTime: 5 * 60 * 1000, // 5 minutes
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: 'always',
|
refetchOnReconnect: "always",
|
||||||
retry: 3,
|
retry: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return routerWithQueryClient(
|
return routerWithQueryClient(
|
||||||
createTanStackRouter({
|
createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: [], withPadding: true },
|
context: {
|
||||||
defaultPreload: 'intent',
|
queryClient,
|
||||||
|
auth: undefined!,
|
||||||
|
header: defaultHeaderConfig,
|
||||||
|
refresh: [],
|
||||||
|
withPadding: true,
|
||||||
|
},
|
||||||
|
defaultPreload: "intent",
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultViewTransition: true
|
defaultViewTransition: true,
|
||||||
}),
|
}),
|
||||||
queryClient,
|
queryClient
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof createRouter>
|
router: ReturnType<typeof createRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,63 @@
|
|||||||
import '@mantine/core/styles.css';
|
import "@mantine/core/styles.css";
|
||||||
import '@mantine/dates/styles.css';
|
import "@mantine/dates/styles.css";
|
||||||
import '@mantine/carousel/styles.css';
|
import "@mantine/carousel/styles.css";
|
||||||
import {
|
import {
|
||||||
HeadContent,
|
HeadContent,
|
||||||
Navigate,
|
Navigate,
|
||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
createRootRouteWithContext
|
createRootRouteWithContext,
|
||||||
} from '@tanstack/react-router'
|
} from "@tanstack/react-router";
|
||||||
import * as React from 'react'
|
import * as React from "react";
|
||||||
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
|
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
|
||||||
import { type QueryClient } from '@tanstack/react-query'
|
import { type QueryClient } from "@tanstack/react-query";
|
||||||
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client'
|
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
|
||||||
import { AuthContextType, authQueryConfig } from '@/contexts/auth-context'
|
import { AuthContextType } from "@/contexts/auth-context";
|
||||||
import Providers from '@/features/core/components/providers'
|
import Providers from "@/features/core/components/providers";
|
||||||
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
|
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
||||||
import { HeaderConfig } from '@/features/core/types/header-config';
|
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<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient;
|
||||||
auth: AuthContextType,
|
auth: AuthContextType;
|
||||||
header: HeaderConfig,
|
header: HeaderConfig;
|
||||||
refresh: string[]
|
refresh: string[];
|
||||||
withPadding: boolean
|
withPadding: boolean;
|
||||||
}>()({
|
}>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{
|
{
|
||||||
charSet: 'utf-8'
|
charSet: "utf-8",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'viewport',
|
name: "viewport",
|
||||||
content: 'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content',
|
content:
|
||||||
}
|
"width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
rel: 'apple-touch-icon',
|
rel: "apple-touch-icon",
|
||||||
sizes: '180x180',
|
sizes: "180x180",
|
||||||
href: '/apple-touch-icon.png',
|
href: "/apple-touch-icon.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: 'icon',
|
rel: "icon",
|
||||||
type: 'image/png',
|
type: "image/png",
|
||||||
sizes: '32x32',
|
sizes: "32x32",
|
||||||
href: '/favicon-32x32.png',
|
href: "/favicon-32x32.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: 'icon',
|
rel: "icon",
|
||||||
type: 'image/png',
|
type: "image/png",
|
||||||
sizes: '16x16',
|
sizes: "16x16",
|
||||||
href: '/favicon-16x16.png',
|
href: "/favicon-16x16.png",
|
||||||
},
|
},
|
||||||
{ rel: 'manifest', href: '/site.webmanifest' },
|
{ rel: "manifest", href: "/site.webmanifest" },
|
||||||
{ rel: 'icon', href: '/favicon.ico' },
|
{ rel: "icon", href: "/favicon.ico" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
errorComponent: (props) => {
|
errorComponent: (props) => {
|
||||||
@@ -61,25 +65,24 @@ export const Route = createRootRouteWithContext<{
|
|||||||
<RootDocument>
|
<RootDocument>
|
||||||
<DefaultCatchBoundary {...props} />
|
<DefaultCatchBoundary {...props} />
|
||||||
</RootDocument>
|
</RootDocument>
|
||||||
)
|
);
|
||||||
},
|
},
|
||||||
component: RootComponent,
|
component: RootComponent,
|
||||||
notFoundComponent: () => <Navigate to="/" />,
|
notFoundComponent: () => <Navigate to="/" />,
|
||||||
beforeLoad: async ({ context }) => {
|
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.
|
const auth = await ensureServerQueryData(
|
||||||
// See: https://github.com/TanStack/router/discussions/3531
|
context.queryClient,
|
||||||
const auth = await context.queryClient.ensureQueryData(authQueryConfig)
|
playerQueries.auth()
|
||||||
return {
|
);
|
||||||
auth
|
return { auth };
|
||||||
};
|
},
|
||||||
}
|
pendingComponent: () => <p>Loading...</p>,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
ensureSuperTokensFrontend()
|
ensureSuperTokensFrontend();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootDocument>
|
<RootDocument>
|
||||||
@@ -87,25 +90,38 @@ function RootComponent() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</Providers>
|
</Providers>
|
||||||
</RootDocument>
|
</RootDocument>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: analytics -> process.env data-website-id
|
// todo: analytics -> process.env data-website-id
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
<html
|
||||||
|
{...mantineHtmlProps}
|
||||||
|
style={{
|
||||||
|
overflowX: "hidden",
|
||||||
|
overflowY: "hidden",
|
||||||
|
position: "fixed",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<HeadContent />
|
<HeadContent />
|
||||||
<ColorSchemeScript />
|
<ColorSchemeScript />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<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>
|
</head>
|
||||||
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
<body
|
||||||
<div className='app'>
|
style={{
|
||||||
{children}
|
overflowX: "hidden",
|
||||||
</div>
|
overflowY: "hidden",
|
||||||
|
position: "fixed",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="app">{children}</div>
|
||||||
<Scripts />
|
<Scripts />
|
||||||
|
<ReactQueryDevtools />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
|
import { redirect, createFileRoute, Outlet } from "@tanstack/react-router";
|
||||||
import Layout from "@/features/core/components/layout";
|
import Layout from "@/features/core/components/layout";
|
||||||
import { useServerEvents } from "@/hooks/use-server-events";
|
import { useServerEvents } from "@/hooks/use-server-events";
|
||||||
|
import { Loader } from "@mantine/core";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed')({
|
export const Route = createFileRoute("/_authed")({
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.auth?.user) {
|
if (!context.auth?.user) {
|
||||||
throw redirect({ to: '/login' })
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
...context.auth,
|
...context.auth,
|
||||||
user: context.auth.user
|
user: context.auth.user,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
component: () => {
|
component: () => {
|
||||||
@@ -21,6 +22,11 @@ export const Route = createFileRoute('/_authed')({
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
})
|
pendingComponent: () => (
|
||||||
|
<Layout>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</Layout>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
import { Outlet, redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin')({
|
export const Route = createFileRoute("/_authed/admin")({
|
||||||
component: Outlet,
|
component: Outlet,
|
||||||
beforeLoad: ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.auth?.roles?.includes('Admin')) {
|
if (!context.auth?.roles?.includes("Admin")) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: "/" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
header: {
|
header: {
|
||||||
...context.header,
|
...context.header,
|
||||||
title: 'Admin',
|
title: "Admin",
|
||||||
withBackButton: true
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { AdminPage } from "@/features/admin";
|
import { AdminPage } from "@/features/admin";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/")({
|
export const Route = createFileRoute("/_authed/admin/")({
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Admin"
|
title: "Admin",
|
||||||
},
|
},
|
||||||
withPadding: false
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
component: () => <AdminPage />
|
component: () => <AdminPage />,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { PreviewBracket } from '@/features/bracket/components/preview'
|
import { PreviewBracket } from "@/features/bracket/components/preview";
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin/preview')({
|
export const Route = createFileRoute("/_authed/admin/preview")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: "Bracket Preview"
|
title: "Bracket Preview",
|
||||||
},
|
},
|
||||||
withPadding: false
|
withPadding: false,
|
||||||
})
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <PreviewBracket />
|
return <PreviewBracket />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
import ManageTournament from '@/features/tournaments/components/manage-tournament'
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
|
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
|
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const tournament = await queryClient.ensureQueryData(tournamentQueries.details(params.id))
|
const tournament = await ensureServerQueryData(
|
||||||
if (!tournament) throw redirect({ to: '/admin/tournaments' });
|
queryClient,
|
||||||
|
tournamentQueries.details(params.id)
|
||||||
|
);
|
||||||
|
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||||
return {
|
return {
|
||||||
tournament
|
tournament,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
loader: ({ context }) => ({
|
loader: ({ context }) => ({
|
||||||
header: {
|
header: {
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: `Manage ${context.tournament.name}`,
|
title: `Manage ${context.tournament.name}`,
|
||||||
},
|
},
|
||||||
withPadding: false
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams();
|
||||||
return <ManageTournament tournamentId={id} />
|
return <ManageTournament tournamentId={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import Page from "@/components/page";
|
|
||||||
import ManageTournaments from "@/features/admin/components/manage-tournaments";
|
import ManageTournaments from "@/features/admin/components/manage-tournaments";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||||
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await queryClient.ensureQueryData(tournamentQueries.list());
|
await prefetchServerQuery(queryClient, tournamentQueries.list());
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -14,11 +14,11 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
|
|||||||
title: "Manage Tournaments",
|
title: "Manage Tournaments",
|
||||||
},
|
},
|
||||||
refresh: tournamentQueries.list().queryKey,
|
refresh: tournamentQueries.list().queryKey,
|
||||||
withPadding: false
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <ManageTournaments />
|
return <ManageTournaments />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import Page from "@/components/page";
|
|
||||||
import { TrophyIcon } from "@phosphor-icons/react";
|
import { TrophyIcon } from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
|
||||||
import { Box, Divider, Text } from "@mantine/core";
|
import { Box, Divider, Text } from "@mantine/core";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/")({
|
export const Route = createFileRoute("/_authed/")({
|
||||||
component: Home,
|
component: Home,
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
await context.queryClient.ensureQueryData(tournamentQueries.list());
|
|
||||||
},
|
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
withPadding: false
|
withPadding: false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Page from "@/components/page";
|
|
||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
@@ -12,22 +12,22 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
|
await prefetchServerQuery(
|
||||||
if (!player) throw redirect({ to: '/' });
|
queryClient,
|
||||||
return {
|
playerQueries.details(params.playerId)
|
||||||
player
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: 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: () => {
|
component: () => {
|
||||||
const { player } = Route.useRouteContext();
|
const { playerId } = Route.useParams();
|
||||||
return <Profile player={player} />
|
return <Profile id={playerId} />;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Box, Title, Stack } from "@mantine/core"
|
import { Box, Title, Stack } from "@mantine/core";
|
||||||
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker"
|
import { ColorSchemePicker } from "@/features/settings/components/color-scheme-picker";
|
||||||
import AccentColorPicker from "@/features/settings/components/accent-color-picker"
|
import AccentColorPicker from "@/features/settings/components/accent-color-picker";
|
||||||
import { SignOutIcon } from "@phosphor-icons/react"
|
import { SignOutIcon } from "@phosphor-icons/react";
|
||||||
import ListLink from "@/components/list-link"
|
import ListLink from "@/components/list-link";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/settings")({
|
export const Route = createFileRoute("/_authed/settings")({
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
title: 'Settings',
|
title: "Settings",
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
withPadding: false
|
withPadding: false,
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
});
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <>
|
return (
|
||||||
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
<>
|
||||||
<Title order={3}>Appearance</Title>
|
<Box
|
||||||
<Stack>
|
px="md"
|
||||||
<AccentColorPicker />
|
py="sm"
|
||||||
<ColorSchemePicker />
|
style={{
|
||||||
</Stack>
|
borderBottom: "1px solid var(--mantine-color-default-border)",
|
||||||
</Box>
|
}}
|
||||||
<ListLink
|
>
|
||||||
label='Sign Out'
|
<Title order={3}>Appearance</Title>
|
||||||
to='/logout'
|
<Stack>
|
||||||
Icon={SignOutIcon}
|
<AccentColorPicker />
|
||||||
/>
|
<ColorSchemePicker />
|
||||||
</>
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<ListLink label="Sign Out" to="/logout" Icon={SignOutIcon} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Page from "@/components/page";
|
|
||||||
import TeamProfile from "@/features/teams/components/team-profile";
|
import TeamProfile from "@/features/teams/components/team-profile";
|
||||||
import { teamQueries } from "@/features/teams/queries";
|
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 { redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -12,19 +13,17 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
|||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
|
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
|
||||||
if (!team) throw redirect({ to: '/' });
|
|
||||||
return { team }
|
|
||||||
},
|
},
|
||||||
loader: ({ params }) => ({
|
loader: ({ params }) => ({
|
||||||
header: {
|
header: {
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: true
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
refresh: [teamQueries.details(params.teamId).queryKey]
|
refresh: [teamQueries.details(params.teamId).queryKey],
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { team } = Route.useRouteContext();
|
const { teamId } = Route.useParams();
|
||||||
return <TeamProfile team={team} />
|
return <TeamProfile id={teamId} />;
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries';
|
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 Profile from '@/features/tournaments/components/profile';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.string().optional(),
|
tab: z.string().optional(),
|
||||||
@@ -20,7 +12,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
|
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -35,6 +27,6 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
|
const tournamentId = Route.useParams().tournamentId;
|
||||||
return <Profile tournament={tournament!} />
|
return <Profile id={tournamentId} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,19 @@ import Page from '@/components/page'
|
|||||||
import { Stack } from '@mantine/core'
|
import { Stack } from '@mantine/core'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
||||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { useAuth } from '@/contexts/auth-context'
|
import { useAuth } from '@/contexts/auth-context'
|
||||||
import { useSheet } from '@/hooks/use-sheet'
|
import { useSheet } from '@/hooks/use-sheet'
|
||||||
import Sheet from '@/components/sheet/sheet'
|
import Sheet from '@/components/sheet/sheet'
|
||||||
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
import TournamentForm from '@/features/tournaments/components/tournament-form'
|
||||||
import { PlusIcon } from '@phosphor-icons/react'
|
import { PlusIcon } from '@phosphor-icons/react'
|
||||||
import Button from '@/components/button'
|
import Button from '@/components/button'
|
||||||
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await queryClient.ensureQueryData(tournamentQueries.list())
|
await prefetchServerQuery(queryClient, tournamentQueries.list())
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -27,7 +27,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: tournaments } = useQuery(tournamentQueries.list());
|
const { data: tournaments } = useTournaments();
|
||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const sheet = useSheet();
|
const sheet = useSheet();
|
||||||
|
|
||||||
|
|||||||
@@ -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!')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -4,17 +4,16 @@ import { redirect, createFileRoute } from "@tanstack/react-router";
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const loginSearchSchema = z.object({
|
const loginSearchSchema = z.object({
|
||||||
stage: z.enum(['code', 'name']).optional(),
|
stage: z.enum(["code", "name"]).optional(),
|
||||||
number: z.string().optional(),
|
number: z.string().optional(),
|
||||||
callback: z.string().optional()
|
callback: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/login")({
|
export const Route = createFileRoute("/login")({
|
||||||
validateSearch: loginSearchSchema,
|
validateSearch: loginSearchSchema,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
if (context.auth?.user) {
|
if (context.auth?.user) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: "/" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: () => {
|
component: () => {
|
||||||
@@ -22,6 +21,6 @@ export const Route = createFileRoute("/login")({
|
|||||||
<LoginLayout>
|
<LoginLayout>
|
||||||
<LoginFlow />
|
<LoginFlow />
|
||||||
</LoginLayout>
|
</LoginLayout>
|
||||||
)
|
);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { LoadingOverlay } from '@mantine/core'
|
import { LoadingOverlay } from "@mantine/core";
|
||||||
import { signOut } from 'supertokens-web-js/recipe/passwordless'
|
import { signOut } from "supertokens-web-js/recipe/passwordless";
|
||||||
import { redirect, createFileRoute } from '@tanstack/react-router'
|
import { redirect, createFileRoute } from "@tanstack/react-router";
|
||||||
import { authQueryConfig, defaultAuthData } from '@/contexts/auth-context'
|
import { defaultAuthData } from "@/contexts/auth-context";
|
||||||
|
import { playerKeys } from "@/features/players/queries";
|
||||||
|
|
||||||
export const Route = createFileRoute('/logout')({
|
export const Route = createFileRoute("/logout")({
|
||||||
preload: false,
|
preload: false,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
await context.queryClient.setQueryData(authQueryConfig.queryKey, defaultAuthData);
|
await context.queryClient.setQueryData(playerKeys.auth, defaultAuthData);
|
||||||
await signOut();
|
await signOut();
|
||||||
throw redirect({ to: '/login' });
|
throw redirect({ to: "/login" });
|
||||||
},
|
},
|
||||||
pendingComponent: () => <LoadingOverlay visible />
|
pendingComponent: () => <LoadingOverlay visible />,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
onClick={toggleDetails}
|
onClick={toggleDetails}
|
||||||
>
|
>
|
||||||
{detailsOpened ? 'Hide' : 'Show'} technical details
|
{detailsOpened ? 'Hide' : 'Show'} stack trace
|
||||||
</Button>
|
</Button>
|
||||||
<Collapse in={detailsOpened}>
|
<Collapse in={detailsOpened}>
|
||||||
<Code block mt="md" p="md">
|
<Code block mt="md" p="md">
|
||||||
|
|||||||
@@ -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;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatar = ({ name, size = 35, radius = '100%', withBorder = true, ...props }: AvatarProps) => {
|
const Avatar = ({
|
||||||
return <Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
name,
|
||||||
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} w='fit-content' styles={{ image: {
|
size = 35,
|
||||||
objectFit: 'contain'
|
radius = "100%",
|
||||||
} }} {...props} />
|
withBorder = true,
|
||||||
</Paper>
|
...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;
|
export default Avatar;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Button as MantineButton, ButtonProps as MantineButtonProps } from '@mantine/core';
|
import {
|
||||||
import { forwardRef, ComponentPropsWithoutRef } from 'react';
|
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) => {
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||||
return <MantineButton fullWidth ref={ref} {...props} />;
|
return <MantineButton fullWidth ref={ref} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
Button.displayName = 'Button';
|
Button.displayName = "Button";
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ interface DateTimePickerProps {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => {
|
const DateTimePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
...rest
|
||||||
|
}: DateTimePickerProps) => {
|
||||||
const timeRef = useRef<HTMLInputElement>(null);
|
const timeRef = useRef<HTMLInputElement>(null);
|
||||||
const currentDate = value ? new Date(value) : null;
|
const currentDate = value ? new Date(value) : null;
|
||||||
|
|
||||||
const formatDate = (date: Date | null): string => {
|
const formatDate = (date: Date | null): string => {
|
||||||
if (!date) return "";
|
if (!date) return "";
|
||||||
return date.toISOString().split('T')[0];
|
return date.toISOString().split("T")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (date: Date | null): string => {
|
const formatTime = (date: Date | null): string => {
|
||||||
@@ -26,35 +31,35 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
|||||||
|
|
||||||
const handleDateChange = (dateString: string | null) => {
|
const handleDateChange = (dateString: string | null) => {
|
||||||
if (!dateString) {
|
if (!dateString) {
|
||||||
onChange('');
|
onChange("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newDate = new Date(dateString + 'T00:00:00');
|
const newDate = new Date(dateString + "T00:00:00");
|
||||||
|
|
||||||
if (currentDate) {
|
if (currentDate) {
|
||||||
newDate.setHours(currentDate.getHours());
|
newDate.setHours(currentDate.getHours());
|
||||||
newDate.setMinutes(currentDate.getMinutes());
|
newDate.setMinutes(currentDate.getMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(newDate.toISOString());
|
onChange(newDate.toISOString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const timeValue = event.target.value;
|
const timeValue = event.target.value;
|
||||||
if (!timeValue) return;
|
if (!timeValue) return;
|
||||||
|
|
||||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||||
if (isNaN(hours) || isNaN(minutes)) return;
|
if (isNaN(hours) || isNaN(minutes)) return;
|
||||||
|
|
||||||
const baseDate = currentDate || new Date();
|
const baseDate = currentDate || new Date();
|
||||||
const newDate = new Date(baseDate);
|
const newDate = new Date(baseDate);
|
||||||
|
|
||||||
newDate.setHours(hours);
|
newDate.setHours(hours);
|
||||||
newDate.setMinutes(minutes);
|
newDate.setMinutes(minutes);
|
||||||
newDate.setSeconds(0);
|
newDate.setSeconds(0);
|
||||||
newDate.setMilliseconds(0);
|
newDate.setMilliseconds(0);
|
||||||
|
|
||||||
onChange(newDate.toISOString());
|
onChange(newDate.toISOString());
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,9 +78,9 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
|||||||
value={formatTime(currentDate)}
|
value={formatTime(currentDate)}
|
||||||
onChange={handleTimeChange}
|
onChange={handleTimeChange}
|
||||||
rightSection={
|
rightSection={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => timeRef.current?.showPicker()}
|
onClick={() => timeRef.current?.showPicker()}
|
||||||
>
|
>
|
||||||
<ClockIcon size={16} />
|
<ClockIcon size={16} />
|
||||||
@@ -87,4 +92,4 @@ const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { DateTimePicker };
|
export { DateTimePicker };
|
||||||
|
|||||||
@@ -10,21 +10,18 @@ interface ListButtonProps {
|
|||||||
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
const ListButton = ({ label, onClick, Icon }: ListButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UnstyledButton
|
<UnstyledButton w="100%" p="md" component={"button"} onClick={onClick}>
|
||||||
w='100%'
|
|
||||||
p='md'
|
|
||||||
component={'button'}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Group>
|
<Group>
|
||||||
<Icon weight='bold' size={20} />
|
<Icon weight="bold" size={20} />
|
||||||
<Text fw={500} size='md'>{label}</Text>
|
<Text fw={500} size="md">
|
||||||
<CaretRightIcon style={{ marginLeft: 'auto' }} size={20} />
|
{label}
|
||||||
|
</Text>
|
||||||
|
<CaretRightIcon style={{ marginLeft: "auto" }} size={20} />
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ListButton;
|
export default ListButton;
|
||||||
|
|||||||
@@ -14,17 +14,21 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NavLink
|
<NavLink
|
||||||
w='100%'
|
w="100%"
|
||||||
p='md'
|
p="md"
|
||||||
component={'button'}
|
component={"button"}
|
||||||
onClick={() => navigate({ to })}
|
onClick={() => navigate({ to })}
|
||||||
label={<Text fw={500} size='md'>{label}</Text>}
|
label={
|
||||||
leftSection={Icon && <Icon weight='bold' size={20} />}
|
<Text fw={500} size="md">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
leftSection={Icon && <Icon weight="bold" size={20} />}
|
||||||
rightSection={<CaretRightIcon size={20} />}
|
rightSection={<CaretRightIcon size={20} />}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ListLink;
|
export default ListLink;
|
||||||
|
|||||||
@@ -7,9 +7,19 @@ interface PageProps extends ContainerProps, React.PropsWithChildren {
|
|||||||
|
|
||||||
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
||||||
const { header } = useRouterConfig();
|
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}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Input, InputProps, Group, Text } from '@mantine/core';
|
import { Input, InputProps, Group, Text } from "@mantine/core";
|
||||||
import { CheckFat, Phone } from '@phosphor-icons/react';
|
import { CheckFat, Phone } from "@phosphor-icons/react";
|
||||||
import { IMaskInput } from 'react-imask';
|
import { IMaskInput } from "react-imask";
|
||||||
|
|
||||||
interface PhoneNumberInputProps extends InputProps {
|
interface PhoneNumberInputProps extends InputProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,24 +11,48 @@ interface PhoneNumberInputProps extends InputProps {
|
|||||||
error?: string;
|
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 (
|
return (
|
||||||
<Input.Wrapper id={id} label={label} description={description} error={error}>
|
<Input.Wrapper
|
||||||
|
id={id}
|
||||||
|
label={label}
|
||||||
|
description={description}
|
||||||
|
error={error}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
component={IMaskInput}
|
component={IMaskInput}
|
||||||
mask="(000) 000-0000"
|
mask="(000) 000-0000"
|
||||||
leftSection={<Group gap={2}><Phone size={20} /> <Text c='dimmed' size='sm'>+1</Text></Group>}
|
leftSection={
|
||||||
|
<Group gap={2}>
|
||||||
|
<Phone size={20} /> {" "}
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
+1
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
leftSectionWidth={50}
|
leftSectionWidth={50}
|
||||||
leftSectionProps={{ style: { padding: 0 } }}
|
leftSectionProps={{ style: { padding: 0 } }}
|
||||||
placeholder="(713) 867-5309"
|
placeholder="(713) 867-5309"
|
||||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
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}
|
value={value}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</Input.Wrapper>
|
</Input.Wrapper>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default PhoneNumberInput;
|
export default PhoneNumberInput;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
import { Box, Container, useComputedColorScheme } from "@mantine/core";
|
||||||
import { PropsWithChildren, useEffect } from "react";
|
import { PropsWithChildren, useEffect } from "react";
|
||||||
import { Drawer as VaulDrawer } from 'vaul';
|
import { Drawer as VaulDrawer } from "vaul";
|
||||||
import { useMantineColorScheme } from '@mantine/core';
|
import { useMantineColorScheme } from "@mantine/core";
|
||||||
import styles from './styles.module.css';
|
import styles from "./styles.module.css";
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -10,44 +10,51 @@ interface DrawerProps extends PropsWithChildren {
|
|||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) => {
|
const Drawer: React.FC<DrawerProps> = ({
|
||||||
const colorScheme = useComputedColorScheme('light');
|
title,
|
||||||
|
children,
|
||||||
|
opened,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const colorScheme = useComputedColorScheme("light");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appElement = document.querySelector('.app') as HTMLElement;
|
const appElement = document.querySelector(".app") as HTMLElement;
|
||||||
|
|
||||||
if (!appElement) return;
|
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) {
|
if (!themeColorMeta) {
|
||||||
themeColorMeta = document.createElement('meta');
|
themeColorMeta = document.createElement("meta");
|
||||||
themeColorMeta.name = 'theme-color';
|
themeColorMeta.name = "theme-color";
|
||||||
document.head.appendChild(themeColorMeta);
|
document.head.appendChild(themeColorMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
light: {
|
light: {
|
||||||
normal: 'rgb(255,255,255)',
|
normal: "rgb(255,255,255)",
|
||||||
overlay: 'rgb(153,153,153)'
|
overlay: "rgb(153,153,153)",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
normal: 'rgb(36,36,36)',
|
normal: "rgb(36,36,36)",
|
||||||
overlay: 'rgb(22,22,22)'
|
overlay: "rgb(22,22,22)",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentColors = colors[colorScheme] || colors.light;
|
const currentColors = colors[colorScheme] || colors.light;
|
||||||
|
|
||||||
if (opened) {
|
if (opened) {
|
||||||
appElement.classList.add('drawer-scaling');
|
appElement.classList.add("drawer-scaling");
|
||||||
themeColorMeta.content = currentColors.overlay;
|
themeColorMeta.content = currentColors.overlay;
|
||||||
} else {
|
} else {
|
||||||
appElement.classList.remove('drawer-scaling');
|
appElement.classList.remove("drawer-scaling");
|
||||||
themeColorMeta.content = currentColors.normal;
|
themeColorMeta.content = currentColors.normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
appElement.classList.remove('drawer-scaling');
|
appElement.classList.remove("drawer-scaling");
|
||||||
themeColorMeta.content = currentColors.normal;
|
themeColorMeta.content = currentColors.normal;
|
||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [opened, colorScheme]);
|
||||||
@@ -57,9 +64,17 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
|
|||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
<VaulDrawer.Content className={styles.drawerContent}>
|
<VaulDrawer.Content className={styles.drawerContent}>
|
||||||
<Container flex={1} p='md'>
|
<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' }} />
|
<Box
|
||||||
<Container mah='fit-content' mx='auto' maw='28rem' px={0}>
|
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>
|
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
@@ -67,7 +82,7 @@ const Drawer: React.FC<DrawerProps> = ({ title, children, opened, onChange }) =>
|
|||||||
</VaulDrawer.Content>
|
</VaulDrawer.Content>
|
||||||
</VaulDrawer.Portal>
|
</VaulDrawer.Portal>
|
||||||
</VaulDrawer.Root>
|
</VaulDrawer.Root>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default Drawer;
|
export default Drawer;
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ interface ModalProps extends PropsWithChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
|
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}
|
{children}
|
||||||
</MantineModal>
|
</MantineModal>
|
||||||
)
|
);
|
||||||
|
|
||||||
export default Modal;
|
export default Modal;
|
||||||
|
|||||||
@@ -17,14 +17,22 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
const SheetComponent = isMobile ? Drawer : Modal;
|
const SheetComponent = isMobile ? Drawer : Modal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
|
<SheetComponent
|
||||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
|
title={title}
|
||||||
<Box mah='70vh'>
|
opened={opened}
|
||||||
{children}
|
onChange={onChange}
|
||||||
</Box>
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<ScrollArea
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
scrollbarSize={8}
|
||||||
|
scrollbars="y"
|
||||||
|
type="scroll"
|
||||||
|
>
|
||||||
|
<Box mah="70vh">{children}</Box>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Sheet;
|
export default Sheet;
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ const SlidePanelField = ({
|
|||||||
const context = useContext(SlidePanelContext);
|
const context = useContext(SlidePanelContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('SlidePanelField must be used within a SlidePanel');
|
throw new Error("SlidePanelField must be used within a SlidePanel");
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (!onChange) return;
|
if (!onChange) return;
|
||||||
|
|
||||||
context.openPanel({
|
context.openPanel({
|
||||||
title,
|
title,
|
||||||
Component,
|
Component,
|
||||||
@@ -64,26 +64,51 @@ const SlidePanelField = ({
|
|||||||
<Box>
|
<Box>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
p='sm'
|
p="sm"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
|
border: error
|
||||||
borderRadius: 'var(--mantine-radius-md)',
|
? "1px solid var(--mantine-color-error)"
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
: "1px solid var(--mantine-color-dimmed)",
|
||||||
textAlign: 'left',
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Stack>
|
<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" fw={500}>
|
||||||
<Text size="sm" c='dimmed'>{displayValue()}</Text>
|
{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>
|
</Stack>
|
||||||
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
|
<CaretRightIcon
|
||||||
|
size={24}
|
||||||
|
weight="thin"
|
||||||
|
style={{ marginRight: "12px" }}
|
||||||
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
</UnstyledButton>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { SlidePanelField };
|
export { SlidePanelField };
|
||||||
|
|||||||
@@ -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 { 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 { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
|
|
||||||
@@ -15,20 +22,15 @@ interface SlidePanelProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const SlidePanel = ({
|
||||||
* SlidePanel is a form component meant to be used inside a drawer/modal
|
children,
|
||||||
* 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,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
submitText = "Submit",
|
submitText = "Submit",
|
||||||
cancelText = "Cancel",
|
cancelText = "Cancel",
|
||||||
maxHeight = "70vh",
|
maxHeight = "70vh",
|
||||||
formProps = {},
|
formProps = {},
|
||||||
loading = false
|
loading = false,
|
||||||
}: SlidePanelProps) => {
|
}: SlidePanelProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||||
@@ -58,54 +60,62 @@ const SlidePanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
|
<SlidePanelContext.Provider value={{ openPanel, closePanel }}>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: "relative",
|
||||||
height: maxHeight,
|
height: maxHeight,
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
transform: isOpen ? 'translateX(-100%)' : 'translateX(0)',
|
transform: isOpen ? "translateX(-100%)" : "translateX(0)",
|
||||||
transition: 'transform 0.3s ease-in-out',
|
transition: "transform 0.3s ease-in-out",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column'
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
{...formProps}
|
{...formProps}
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
...formProps.style,
|
...formProps.style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='always'>
|
<ScrollArea
|
||||||
<Box p="md">
|
style={{ flex: 1 }}
|
||||||
{children}
|
scrollbarSize={8}
|
||||||
</Box>
|
scrollbars="y"
|
||||||
|
type="always"
|
||||||
|
>
|
||||||
|
<Box p="md">{children}</Box>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<Box p="sm">
|
<Box p="sm">
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<Button type="submit" fullWidth loading={loading} disabled={loading}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
{submitText}
|
{submitText}
|
||||||
</Button>
|
</Button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -120,32 +130,41 @@ const SlidePanel = ({
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
|
transform: isOpen ? "translateX(0)" : "translateX(100%)",
|
||||||
transition: 'transform 0.3s ease-in-out',
|
transition: "transform 0.3s ease-in-out",
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{panelConfig && (
|
{panelConfig && (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" p="md" align="center" w='100%'>
|
<Group justify="space-between" p="md" align="center" w="100%">
|
||||||
<ActionIcon variant="transparent" onClick={closePanel}>
|
<ActionIcon variant="transparent" onClick={closePanel}>
|
||||||
<ArrowLeftIcon size={24} />
|
<ArrowLeftIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<Text fw={500}>{panelConfig.title}</Text>
|
<Text fw={500}>{panelConfig.title}</Text>
|
||||||
<ActionIcon variant="transparent" color="green" onClick={handleConfirm}>
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="green"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
<CheckIcon size={24} />
|
<CheckIcon size={24} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</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>
|
<Box>
|
||||||
<panelConfig.Component
|
<panelConfig.Component
|
||||||
@@ -162,4 +181,4 @@ const SlidePanel = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { SlidePanel };
|
export { SlidePanel };
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
|
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
|
||||||
import { Carousel } from "@mantine/carousel";
|
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";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
@@ -14,15 +21,21 @@ interface SwipeableTabsProps {
|
|||||||
onTabChange?: (index: number, tab: TabItem) => void;
|
onTabChange?: (index: number, tab: TabItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
|
function SwipeableTabs({
|
||||||
|
tabs,
|
||||||
|
defaultTab = 0,
|
||||||
|
onTabChange,
|
||||||
|
}: SwipeableTabsProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = router.state.location.search as any;
|
const search = router.state.location.search as any;
|
||||||
const [embla, setEmbla] = useState<any>(null);
|
const [embla, setEmbla] = useState<any>(null);
|
||||||
|
|
||||||
const getActiveTabFromUrl = useCallback(() => {
|
const getActiveTabFromUrl = useCallback(() => {
|
||||||
const urlTab = search?.tab;
|
const urlTab = search?.tab;
|
||||||
if (typeof urlTab === 'string') {
|
if (typeof urlTab === "string") {
|
||||||
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
|
const tabIndex = tabs.findIndex(
|
||||||
|
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
|
||||||
|
);
|
||||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||||
}
|
}
|
||||||
return defaultTab;
|
return defaultTab;
|
||||||
@@ -32,22 +45,25 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||||
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
|
const controlsRefs = useRef<Record<number, HTMLSpanElement | null>>({});
|
||||||
const slideRefs = useRef<Record<number, HTMLDivElement | 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(
|
||||||
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
(index: number) => {
|
||||||
|
if (index === activeTab || index < 0 || index >= tabs.length) return;
|
||||||
setActiveTab(index);
|
|
||||||
embla?.scrollTo(index);
|
setActiveTab(index);
|
||||||
onTabChange?.(index, tabs[index]);
|
embla?.scrollTo(index);
|
||||||
|
onTabChange?.(index, tabs[index]);
|
||||||
const tabLabel = tabs[index].label.toLowerCase();
|
|
||||||
if (typeof window !== 'undefined') {
|
const tabLabel = tabs[index].label.toLowerCase();
|
||||||
const url = new URL(window.location.href);
|
if (typeof window !== "undefined") {
|
||||||
url.searchParams.set('tab', tabLabel);
|
const url = new URL(window.location.href);
|
||||||
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(() => {
|
const handleEmblaSelect = useCallback(() => {
|
||||||
if (!embla) return;
|
if (!embla) return;
|
||||||
@@ -78,13 +94,19 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const setControlRef = useCallback((index: number) => (node: HTMLSpanElement | null) => {
|
const setControlRef = useCallback(
|
||||||
controlsRefs.current[index] = node;
|
(index: number) => (node: HTMLSpanElement | null) => {
|
||||||
}, []);
|
controlsRefs.current[index] = node;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const setSlideRef = useCallback((index: number) => (node: HTMLDivElement | null) => {
|
const setSlideRef = useCallback(
|
||||||
slideRefs.current[index] = node;
|
(index: number) => (node: HTMLDivElement | null) => {
|
||||||
}, []);
|
slideRefs.current[index] = node;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -93,10 +115,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
pos="sticky"
|
pos="sticky"
|
||||||
top={0}
|
top={0}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
marginBottom: 'var(--mantine-spacing-md)',
|
marginBottom: "var(--mantine-spacing-md)",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
backgroundColor: 'var(--mantine-color-body)'
|
backgroundColor: "var(--mantine-color-body)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FloatingIndicator
|
<FloatingIndicator
|
||||||
@@ -104,9 +126,9 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
parent={rootRef}
|
parent={rootRef}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
borderBottom: '2px solid var(--mantine-primary-color-filled)',
|
borderBottom: "2px solid var(--mantine-primary-color-filled)",
|
||||||
paddingInline: '0.5rem'
|
paddingInline: "0.5rem",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
@@ -115,25 +137,26 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
onClick={() => changeTab(index)}
|
onClick={() => changeTab(index)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
|
padding: "var(--mantine-spacing-sm) var(--mantine-spacing-md)",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
color: activeTab === index
|
color:
|
||||||
? 'var(--mantine-color-blue-6)'
|
activeTab === index
|
||||||
: 'var(--mantine-color-text)',
|
? "var(--mantine-primary-color-filled)"
|
||||||
|
: "var(--mantine-color-text)",
|
||||||
fontWeight: activeTab === index ? 600 : 400,
|
fontWeight: activeTab === index ? 600 : 400,
|
||||||
transition: 'color 200ms ease, font-weight 200ms ease',
|
transition: "color 200ms ease, font-weight 200ms ease",
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: "transparent",
|
||||||
border: 'none',
|
border: "none",
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
component="span"
|
component="span"
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: "inline-block",
|
||||||
paddingInline: '1rem',
|
paddingInline: "1rem",
|
||||||
paddingBottom: '0.25rem'
|
paddingBottom: "0.25rem",
|
||||||
}}
|
}}
|
||||||
ref={setControlRef(index)}
|
ref={setControlRef(index)}
|
||||||
>
|
>
|
||||||
@@ -150,17 +173,14 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
slideSize="100%"
|
slideSize="100%"
|
||||||
initialSlide={activeTab}
|
initialSlide={activeTab}
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
|
height: carouselHeight === "auto" ? "auto" : `${carouselHeight}px`,
|
||||||
transition: 'height 300ms ease'
|
transition: "height 300ms ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||||
<Box
|
<Box ref={setSlideRef(index)} style={{ height: "auto" }}>
|
||||||
ref={setSlideRef(index)}
|
|
||||||
style={{ height: 'auto' }}
|
|
||||||
>
|
|
||||||
{tab.content}
|
{tab.content}
|
||||||
</Box>
|
</Box>
|
||||||
</Carousel.Slide>
|
</Carousel.Slide>
|
||||||
@@ -170,4 +190,4 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SwipeableTabs;
|
export default SwipeableTabs;
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react";
|
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react";
|
||||||
import { MantineColor, MantineColorScheme } from "@mantine/core";
|
import { MantineColor, MantineColorScheme } from "@mantine/core";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchMe } from "@/features/players/server";
|
|
||||||
import { Player } from "@/features/players/types";
|
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 {
|
interface AuthData {
|
||||||
user: Player | undefined;
|
user: Player | undefined;
|
||||||
@@ -32,11 +27,11 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data, isLoading } = useQuery<AuthData>(authQueryConfig);
|
const { data } = useMe();
|
||||||
|
|
||||||
const set = useCallback((updates: Partial<AuthData>) => {
|
const set = useCallback((updates: Partial<AuthData>) => {
|
||||||
queryClient.setQueryData(queryKey, (oldData: AuthData | undefined) => {
|
queryClient.setQueryData(playerKeys.auth, (oldData: AuthData | undefined) => {
|
||||||
const currentData = oldData || defaultAuthData;
|
const currentData = oldData || defaultAuthData;
|
||||||
return {
|
return {
|
||||||
...currentData,
|
...currentData,
|
||||||
@@ -48,10 +43,6 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
});
|
});
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <p>Loading...</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext
|
<AuthContext
|
||||||
value={{
|
value={{
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { List } from "@mantine/core";
|
import { List } from "@mantine/core";
|
||||||
import ListLink from "@/components/list-link";
|
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";
|
import ListButton from "@/components/list-button";
|
||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
|
<ListLink
|
||||||
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
|
label="Manage Tournaments"
|
||||||
<ListLink label="Bracket Preview" Icon={TreeStructureIcon} to="/admin/preview" />
|
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>
|
</List>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AdminPage;
|
export default AdminPage;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { List } from "@mantine/core";
|
import { List } from "@mantine/core";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useTournaments } from "@/features/tournaments/queries";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
|
||||||
import ListLink from "@/components/list-link";
|
import ListLink from "@/components/list-link";
|
||||||
|
|
||||||
const ManageTournaments = () => {
|
const ManageTournaments = () => {
|
||||||
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
|
const { data: tournaments } = useTournaments();
|
||||||
return (
|
return (
|
||||||
<List>
|
<List>
|
||||||
{tournaments.map(t => (
|
{tournaments.map((t) => (
|
||||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Logger } from "@/lib/logger";
|
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";
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
|
|||||||
import { BracketMaps } from "../utils/bracket-maps";
|
import { BracketMaps } from "../utils/bracket-maps";
|
||||||
|
|
||||||
interface BracketProps {
|
interface BracketProps {
|
||||||
winners: Match[][],
|
winners: Match[][];
|
||||||
losers?: Match[][],
|
losers?: Match[][];
|
||||||
bracketMaps: BracketMaps | null
|
bracketMaps: BracketMaps | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
|
|
||||||
if (!bracketMaps) return <p>Bracket not available.</p>
|
if (!bracketMaps) return <p>Bracket not available.</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
@@ -31,14 +31,14 @@ const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<BracketView bracket={winners} bracketMaps={bracketMaps} />
|
<BracketView bracket={winners} bracketMaps={bracketMaps} />
|
||||||
</div>
|
</div>
|
||||||
{
|
{losers && (
|
||||||
losers && <div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<BracketView bracket={losers} bracketMaps={bracketMaps} />
|
<BracketView bracket={losers} bracketMaps={bracketMaps} />
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,43 +1,51 @@
|
|||||||
import { Flex, Text } from '@mantine/core';
|
import { Flex, Text } from "@mantine/core";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { SeedBadge } from './seed-badge';
|
import { SeedBadge } from "./seed-badge";
|
||||||
|
|
||||||
interface MatchSlotProps {
|
interface MatchSlotProps {
|
||||||
slot: any;
|
slot: any;
|
||||||
getParentMatchOrder: (parentLid: number) => number | string;
|
getParentMatchOrder: (parentLid: number) => number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({ slot, getParentMatchOrder }) => {
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
|
slot,
|
||||||
|
getParentMatchOrder,
|
||||||
|
}) => {
|
||||||
const renderSlotContent = () => {
|
const renderSlotContent = () => {
|
||||||
if (slot?.seed) {
|
if (slot?.seed) {
|
||||||
return slot.team ? (
|
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}
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slot) {
|
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
|
||||||
return <Text c='dimmed' size='xs' fs='italic'>TBD</Text>;
|
return (
|
||||||
|
<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 null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex align="stretch">
|
<Flex align="stretch">
|
||||||
{slot?.seed && <SeedBadge seed={slot.seed} />}
|
{slot?.seed && <SeedBadge seed={slot.seed} />}
|
||||||
<div style={{ flex: 1, padding: '4px 8px' }}>
|
<div style={{ flex: 1, padding: "4px 8px" }}>{renderSlotContent()}</div>
|
||||||
{renderSlotContent()}
|
|
||||||
</div>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { bracketQueries } from "../queries";
|
import { bracketQueries, useBracketPreview } from "../queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
||||||
import { BracketData, Match } from "../types";
|
import { BracketData, Match } from "../types";
|
||||||
@@ -21,9 +21,7 @@ interface PreviewTeam {
|
|||||||
|
|
||||||
export const PreviewBracket: React.FC = () => {
|
export const PreviewBracket: React.FC = () => {
|
||||||
const [teamCount, setTeamCount] = useState(20);
|
const [teamCount, setTeamCount] = useState(20);
|
||||||
const { data, isLoading, error } = useQuery(
|
const { data, isLoading, error } = useBracketPreview(teamCount);
|
||||||
bracketQueries.preview(teamCount)
|
|
||||||
);
|
|
||||||
|
|
||||||
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
||||||
|
|
||||||
@@ -45,7 +43,7 @@ export const PreviewBracket: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data || teams.length === 0) return;
|
if (!data || teams.length === 0) return;
|
||||||
|
|
||||||
const maps = createBracketMaps(data as BracketData);
|
const maps = createBracketMaps(data);
|
||||||
setBracketMaps(maps);
|
setBracketMaps(maps);
|
||||||
|
|
||||||
const mapBracket = (bracket: Match[][]) => {
|
const mapBracket = (bracket: Match[][]) => {
|
||||||
@@ -88,7 +86,7 @@ export const PreviewBracket: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
||||||
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
<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">
|
<Text size="sm" c="dimmed">
|
||||||
Teams:
|
Teams:
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Text } from '@mantine/core';
|
import { Text } from "@mantine/core";
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
|
|
||||||
interface SeedBadgeProps {
|
interface SeedBadgeProps {
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -13,17 +13,17 @@ export const SeedBadge: React.FC<SeedBadgeProps> = ({ seed }) => {
|
|||||||
py="4"
|
py="4"
|
||||||
bg="var(--mantine-color-default-hover)"
|
bg="var(--mantine-color-default-hover)"
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: "32px",
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
color: 'var(--mantine-color-text)',
|
color: "var(--mantine-color-text)",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
borderTopLeftRadius: 'var(--mantine-radius-default)',
|
borderTopLeftRadius: "var(--mantine-radius-default)",
|
||||||
borderBottomLeftRadius: 'var(--mantine-radius-default)',
|
borderBottomLeftRadius: "var(--mantine-radius-default)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{seed}
|
{seed}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Flex, Text, Select, Card } from '@mantine/core';
|
import { Flex, Text, Select, Card } from "@mantine/core";
|
||||||
|
|
||||||
interface Team {
|
interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,11 +13,11 @@ interface SeedListProps {
|
|||||||
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||||
const seedOptions = teams.map((_, index) => ({
|
const seedOptions = teams.map((_, index) => ({
|
||||||
value: index.toString(),
|
value: index.toString(),
|
||||||
label: `Seed ${index + 1}`
|
label: `Seed ${index + 1}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction='column' gap={8}>
|
<Flex direction="column" gap={8}>
|
||||||
{teams.map((team, index) => (
|
{teams.map((team, index) => (
|
||||||
<Card key={team.id} withBorder p="xs">
|
<Card key={team.id} withBorder p="xs">
|
||||||
<Flex align="center" gap="xs" justify="space-between">
|
<Flex align="center" gap="xs" justify="space-between">
|
||||||
@@ -45,4 +45,4 @@ export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
|||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
import { previewBracket } from "./server";
|
import { previewBracket } from "./server";
|
||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { BracketData } from "./types";
|
||||||
|
|
||||||
const bracketKeys = {
|
const bracketKeys = {
|
||||||
preview: (teams: number) => ['bracket', 'preview', teams] as const,
|
preview: (teams: number) => ["bracket", "preview", teams] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const bracketQueries = {
|
export const bracketQueries = {
|
||||||
preview: (teams: number) => queryOptions({
|
preview: (teams: number) => ({
|
||||||
queryKey: bracketKeys.preview(teams),
|
queryKey: bracketKeys.preview(teams),
|
||||||
queryFn: () => previewBracket({ data: teams }),
|
queryFn: () => previewBracket({ data: teams }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useBracketPreview = (teams: number) =>
|
||||||
|
useServerSuspenseQuery<BracketData>(bracketQueries.preview(teams));
|
||||||
|
|||||||
@@ -2,17 +2,20 @@ import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import brackets from './utils';
|
import brackets from "./utils";
|
||||||
import { BracketData } from "./types";
|
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()
|
export const previewBracket = createServerFn()
|
||||||
.validator(z.number())
|
.validator(z.number())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teams }) => {
|
.handler(async ({ data: teams }) =>
|
||||||
logger.info('Generating bracket', teams);
|
toServerResult(async () => {
|
||||||
if (!Object.keys(brackets).includes(teams.toString()))
|
logger.info("Generating bracket", teams);
|
||||||
throw Error("Bracket not available")
|
if (!Object.keys(brackets).includes(teams.toString()))
|
||||||
|
throw Error("Bracket not available");
|
||||||
return brackets[teams as keyof typeof brackets] as BracketData;
|
return brackets[teams as keyof typeof brackets] as BracketData;
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
export interface Slot {
|
export interface Slot {
|
||||||
seed?: number;
|
seed?: number;
|
||||||
team?: any;
|
team?: any;
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
|||||||
const matchByOrder = new Map<number, Match>();
|
const matchByOrder = new Map<number, Match>();
|
||||||
const allMatches: Match[] = [];
|
const allMatches: Match[] = [];
|
||||||
|
|
||||||
[...bracketData.winners, ...bracketData.losers].forEach(round => {
|
[...bracketData.winners, ...bracketData.losers].forEach((round) => {
|
||||||
round.forEach(match => {
|
round.forEach((match) => {
|
||||||
matchByLid.set(match.lid, match);
|
matchByLid.set(match.lid, match);
|
||||||
|
|
||||||
if (match.order !== null && match.order !== undefined) {
|
if (match.order !== null && match.order !== undefined) {
|
||||||
matchByOrder.set(match.order, match);
|
matchByOrder.set(match.order, match);
|
||||||
}
|
}
|
||||||
|
|
||||||
allMatches.push(match);
|
allMatches.push(match);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -26,14 +26,20 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
|||||||
return {
|
return {
|
||||||
matchByLid,
|
matchByLid,
|
||||||
matchByOrder,
|
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);
|
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);
|
return maps.matchByOrder.get(order);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
* 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 b12 from "../../../../scripts/brackets/12.json";
|
||||||
import b13 from '../../../../scripts/brackets/13.json';
|
import b13 from "../../../../scripts/brackets/13.json";
|
||||||
import b14 from '../../../../scripts/brackets/14.json';
|
import b14 from "../../../../scripts/brackets/14.json";
|
||||||
import b15 from '../../../../scripts/brackets/15.json';
|
import b15 from "../../../../scripts/brackets/15.json";
|
||||||
import b16 from '../../../../scripts/brackets/16.json';
|
import b16 from "../../../../scripts/brackets/16.json";
|
||||||
import b17 from '../../../../scripts/brackets/17.json';
|
import b17 from "../../../../scripts/brackets/17.json";
|
||||||
import b18 from '../../../../scripts/brackets/18.json';
|
import b18 from "../../../../scripts/brackets/18.json";
|
||||||
import b19 from '../../../../scripts/brackets/19.json';
|
import b19 from "../../../../scripts/brackets/19.json";
|
||||||
import b20 from '../../../../scripts/brackets/20.json';
|
import b20 from "../../../../scripts/brackets/20.json";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
12: b12,
|
12: b12,
|
||||||
13: b13,
|
13: b13,
|
||||||
14: b14,
|
14: b14,
|
||||||
15: b15,
|
15: b15,
|
||||||
16: b16,
|
16: b16,
|
||||||
17: b17,
|
17: b17,
|
||||||
18: b18,
|
18: b18,
|
||||||
19: b19,
|
19: b19,
|
||||||
20: b20,
|
20: b20,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ interface NavLinkProps {
|
|||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
Icon: Icon;
|
Icon: Icon;
|
||||||
|
include?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
|
export const NavLink = ({ href, label, Icon, include }: NavLinkProps) => {
|
||||||
const router = useRouterState();
|
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 (
|
return (
|
||||||
<Box component={Link} to={href}
|
<Box component={Link} to={href}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLinks } from "../hooks/use-links";
|
|||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useMemo } from "react";
|
|||||||
|
|
||||||
export const useLinks = (userId: string | undefined, roles: string[]) =>
|
export const useLinks = (userId: string | undefined, roles: string[]) =>
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!userId) throw new Error("userId is undefined")
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
@@ -23,7 +22,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
|
|||||||
{
|
{
|
||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
href: `/profile/${userId}`,
|
href: `/profile/${userId}`,
|
||||||
Icon: UserCircleIcon
|
Icon: UserCircleIcon,
|
||||||
|
include: ['/settings']
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, FormEventHandler, useMemo } from 'react';
|
import { useState, FormEventHandler, useMemo } from 'react';
|
||||||
import { ArrowLeftIcon } from '@phosphor-icons/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 { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
|
||||||
import ExistingPlayerButton from './existing-player-button';
|
import ExistingPlayerButton from './existing-player-button';
|
||||||
import NewPlayerButton from './new-player-button';
|
import NewPlayerButton from './new-player-button';
|
||||||
import { Player } from '@/features/players/types';
|
import { Player } from '@/features/players/types';
|
||||||
import { toast } from 'sonner';
|
import toast from '@/lib/sonner'
|
||||||
import { playerQueries } from '@/features/players/queries';
|
import { useUnassociatedPlayers } from '@/features/players/queries';
|
||||||
import useCreateUser from '../../hooks/use-create-user';
|
import useCreateUser from '../../hooks/use-create-user';
|
||||||
import Button from '@/components/button';
|
import Button from '@/components/button';
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ enum PlayerPromptStage {
|
|||||||
|
|
||||||
const PlayerPrompt = () => {
|
const PlayerPrompt = () => {
|
||||||
const [stage, setStage] = useState<PlayerPromptStage>();
|
const [stage, setStage] = useState<PlayerPromptStage>();
|
||||||
const playersQuery = useQuery(playerQueries.unassociated());
|
const playersQuery = useUnassociatedPlayers();
|
||||||
const { mutate: createUser, isPending } = useCreateUser();
|
const { mutate: createUser, isPending } = useCreateUser();
|
||||||
const players = playersQuery.data;
|
const players = playersQuery.data;
|
||||||
const [player, setPlayer] = useState<Player>();
|
const [player, setPlayer] = useState<Player>();
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { consumeCode } from "supertokens-web-js/recipe/passwordless";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchMe } from "@/features/players/server";
|
import { fetchMe } from "@/features/players/server";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { authQueryConfig } from "@/contexts/auth-context";
|
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
|
import { playerKeys } from "@/features/players/queries";
|
||||||
|
|
||||||
const useConsumeCode = (onWrongCode: () => void) => {
|
const useConsumeCode = (onWrongCode: () => void) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -13,9 +13,9 @@ const useConsumeCode = (onWrongCode: () => void) => {
|
|||||||
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
|
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
if (data.status === 'OK') {
|
if (data.status === 'OK') {
|
||||||
const data = await fetchMe();
|
const response = await fetchMe();
|
||||||
queryClient.setQueryData(authQueryConfig.queryKey, data);
|
queryClient.setQueryData(playerKeys.auth, data);
|
||||||
if (!data || !data.user) {
|
if (!response.success || !response.data.user) {
|
||||||
navigate({ to: '/login', search: { stage: 'name' } });
|
navigate({ to: '/login', search: { stage: 'name' } });
|
||||||
} else {
|
} else {
|
||||||
toast.success('Successfully logged in. Welcome back!');
|
toast.success('Successfully logged in. Welcome back!');
|
||||||
|
|||||||
@@ -1,41 +1,26 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { authQueryConfig } from "@/contexts/auth-context";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { associatePlayer, createPlayer } from "@/features/players/server";
|
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 useCreateUser = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: { first_name: string, last_name: string } | string) => {
|
mutationFn: (data: { first_name: string, last_name: string } | string) =>
|
||||||
if (typeof data === 'string') {
|
typeof data === 'string' ?
|
||||||
return associatePlayer({ data });
|
associatePlayer({ data })
|
||||||
} else {
|
: createPlayer({ data }),
|
||||||
return createPlayer({ data });
|
successMessage: 'Account created successfully!',
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (!data) {
|
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
|
||||||
toast.error('There was an issue creating your account. Please try again later.');
|
|
||||||
navigate({ to: '/login' });
|
|
||||||
} else {
|
|
||||||
queryClient.setQueryData(authQueryConfig.queryKey, (old: any) => ({
|
|
||||||
...old,
|
...old,
|
||||||
user: data
|
user: data
|
||||||
}));
|
}));
|
||||||
toast.success('Account created successfully!');
|
navigate({ to: '/' });
|
||||||
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.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Box, Button, Text, Title } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import { testEvent } from "@/utils/test-event";
|
|
||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import { usePlayer } from "../../queries";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
player: Player;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Profile = ({ player }: ProfileProps) => {
|
const Profile = ({ id }: ProfileProps) => {
|
||||||
|
const { data: player } = usePlayer(id);
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
@@ -20,10 +20,8 @@ const Profile = ({ player }: ProfileProps) => {
|
|||||||
content: <Text p="md">Matches feed will go here</Text>
|
content: <Text p="md">Matches feed will go here</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
content: <>
|
content: <Text p="md">Teams will go here</Text>
|
||||||
<TeamList teams={player.teams || []} />
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { updatePlayer } from "@/features/players/server";
|
import { updatePlayer } from "@/features/players/server";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { Stack, TextInput } from "@mantine/core";
|
import { Stack, TextInput } from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import toast from "@/lib/sonner";
|
|
||||||
import { useRouter } from "@tanstack/react-router";
|
|
||||||
import { Player } from "../../types";
|
import { Player } from "../../types";
|
||||||
import Button from "@/components/button";
|
import Button from "@/components/button";
|
||||||
|
import { useOptimisticMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
import { playerKeys } from "../../queries";
|
||||||
|
|
||||||
interface NameUpdateFormProps {
|
interface NameUpdateFormProps {
|
||||||
player: Player;
|
player: Player;
|
||||||
@@ -13,8 +12,6 @@ interface NameUpdateFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
first_name: player.first_name,
|
first_name: player.first_name,
|
||||||
@@ -23,30 +20,32 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
|||||||
validate: {
|
validate: {
|
||||||
first_name: (value: string | undefined) => {
|
first_name: (value: string | undefined) => {
|
||||||
if (!value || value.length === 0) return "First name is required";
|
if (!value || value.length === 0) return "First name is required";
|
||||||
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
|
||||||
return "First name must be 3-20 characters long and contain only letters";
|
return "First name must be 2-20 characters long and contain only letters";
|
||||||
},
|
},
|
||||||
last_name: (value: string | undefined) => {
|
last_name: (value: string | undefined) => {
|
||||||
if (!value || value.length === 0) return "Last name is required";
|
if (!value || value.length === 0) return "Last name is required";
|
||||||
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
|
||||||
return "Last name must be 3-20 characters long and contain only letters";
|
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 }) =>
|
mutationFn: async (data: { first_name: string; last_name: string }) =>
|
||||||
await updatePlayer({ data }),
|
await updatePlayer({ data }),
|
||||||
onSuccess: () => {
|
onSuccess: toggle,
|
||||||
toggle();
|
onError: toggle,
|
||||||
toast.success("Name updated successfully!");
|
successMessage: "Name updated successfully!",
|
||||||
router.invalidate();
|
optimisticUpdate: (oldData, variables) => {
|
||||||
},
|
if (!oldData) return oldData;
|
||||||
onError: () => {
|
return {
|
||||||
toast.error(
|
...oldData,
|
||||||
"There was an issue updating your name. Please try again later."
|
first_name: variables.first_name,
|
||||||
);
|
last_name: variables.last_name,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
queryKey: playerKeys.details(player.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (data: {
|
const handleSubmit = async (data: {
|
||||||
|
|||||||
@@ -1,23 +1,49 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
|
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
|
||||||
|
|
||||||
const playerKeys = {
|
export const playerKeys = {
|
||||||
list: ['players', 'list'] as const,
|
auth: ['auth'],
|
||||||
details: (id: string) => ['players', 'details', id] as const,
|
list: ['players', 'list'],
|
||||||
unassociated: ['players','unassociated'] as const,
|
details: (id: string) => ['players', 'details', id],
|
||||||
|
unassociated: ['players','unassociated'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerQueries = {
|
export const playerQueries = {
|
||||||
list: () => queryOptions({
|
auth: () => ({
|
||||||
|
queryKey: playerKeys.auth,
|
||||||
|
queryFn: async () => await fetchMe()
|
||||||
|
}),
|
||||||
|
list: () => ({
|
||||||
queryKey: playerKeys.list,
|
queryKey: playerKeys.list,
|
||||||
queryFn: listPlayers,
|
queryFn: async () => await listPlayers()
|
||||||
}),
|
}),
|
||||||
details: (id: string) => queryOptions({
|
details: (id: string) => ({
|
||||||
queryKey: playerKeys.details(id),
|
queryKey: playerKeys.details(id),
|
||||||
queryFn: () => getPlayer({ data: id }),
|
queryFn: async () => await getPlayer({ data: id })
|
||||||
}),
|
}),
|
||||||
unassociated: () => queryOptions({
|
unassociated: () => ({
|
||||||
queryKey: playerKeys.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());
|
||||||
@@ -1,60 +1,48 @@
|
|||||||
import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens";
|
import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
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 { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { getWebRequest } from "@tanstack/react-start/server";
|
import { getWebRequest } from "@tanstack/react-start/server";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
|
||||||
export const fetchMe = createServerFn()
|
export const fetchMe = createServerFn()
|
||||||
.handler(async ({ response }) => {
|
.handler(async ({ response }) =>
|
||||||
const request = getWebRequest();
|
toServerResult(async () => {
|
||||||
const { context } = await verifySuperTokensSession(request, response);
|
const request = getWebRequest();
|
||||||
|
const { context } = await verifySuperTokensSession(request, response);
|
||||||
|
|
||||||
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
||||||
|
|
||||||
try {
|
|
||||||
await pbAdmin.authPromise;
|
await pbAdmin.authPromise;
|
||||||
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||||
logger.info('Fetched player', result);
|
|
||||||
return {
|
return {
|
||||||
user: result || undefined,
|
user: result || undefined,
|
||||||
roles: context.roles,
|
roles: context.roles,
|
||||||
metadata: context.metadata
|
metadata: context.metadata
|
||||||
};
|
};
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('Error fetching player:', error);
|
);
|
||||||
return { user: undefined, roles: context.roles, metadata: context.metadata };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getPlayer = createServerFn()
|
export const getPlayer = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) =>
|
||||||
try {
|
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||||
const player = await pbAdmin.getPlayer(data);
|
);
|
||||||
return player;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting player', error);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updatePlayer = createServerFn()
|
export const updatePlayer = createServerFn()
|
||||||
.validator(playerUpdateSchema)
|
.validator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) =>
|
||||||
const userAuthId = context.userAuthId;
|
toServerResult(async () => {
|
||||||
if (!userAuthId) return;
|
const userAuthId = context.userAuthId;
|
||||||
|
if (!userAuthId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
// Find the player by authId first
|
|
||||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||||
|
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
|
|
||||||
// Update the player
|
|
||||||
const updatedPlayer = await pbAdmin.updatePlayer(
|
const updatedPlayer = await pbAdmin.updatePlayer(
|
||||||
existing.id!,
|
existing.id!,
|
||||||
{
|
{
|
||||||
@@ -68,20 +56,17 @@ export const updatePlayer = createServerFn()
|
|||||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
||||||
|
|
||||||
return updatedPlayer;
|
return updatedPlayer;
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('Error updating player name', error);
|
);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.validator(playerInputSchema)
|
.validator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) =>
|
||||||
const userAuthId = context.userAuthId;
|
toServerResult(async () => {
|
||||||
if (!userAuthId) return;
|
const userAuthId = context.userAuthId;
|
||||||
|
if (!userAuthId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||||
if (existing) return;
|
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() } });
|
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
||||||
logger.info('Created player', newPlayer);
|
logger.info('Created player', newPlayer);
|
||||||
return newPlayer;
|
return newPlayer;
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('Error creating player', error);
|
);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) => {
|
.handler(async ({ context, data }) =>
|
||||||
const userAuthId = context.userAuthId;
|
toServerResult(async () => {
|
||||||
if (!userAuthId) return;
|
const userAuthId = context.userAuthId;
|
||||||
|
if (!userAuthId) return;
|
||||||
|
|
||||||
try {
|
|
||||||
await pbAdmin.updatePlayer(data, {
|
await pbAdmin.updatePlayer(data, {
|
||||||
auth_id: userAuthId
|
auth_id: userAuthId
|
||||||
});
|
});
|
||||||
@@ -119,30 +101,17 @@ export const associatePlayer = createServerFn()
|
|||||||
const player = await pbAdmin.getPlayer(data);
|
const player = await pbAdmin.getPlayer(data);
|
||||||
logger.info('Associated player', player);
|
logger.info('Associated player', player);
|
||||||
return player;
|
return player;
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('Error associating player', error);
|
);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const listPlayers = createServerFn()
|
export const listPlayers = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async () => {
|
.handler(async () =>
|
||||||
try {
|
toServerResult(pbAdmin.listPlayers)
|
||||||
return await pbAdmin.listPlayers();
|
);
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error listing players', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getUnassociatedPlayers = createServerFn()
|
export const getUnassociatedPlayers = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async () => {
|
.handler(async () =>
|
||||||
try {
|
toServerResult(pbAdmin.getUnassociatedPlayers)
|
||||||
return await pbAdmin.getUnassociatedPlayers();
|
);
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error getting unassociated players', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -13,8 +13,8 @@ export interface Player {
|
|||||||
|
|
||||||
export const playerInputSchema = z.object({
|
export const playerInputSchema = z.object({
|
||||||
auth_id: z.string().optional(),
|
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"),
|
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(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-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();
|
export const playerUpdateSchema = playerInputSchema.partial();
|
||||||
|
|||||||
@@ -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 Avatar from "@/components/avatar";
|
||||||
import { Team } from "@/features/teams/types";
|
import { Team } from "@/features/teams/types";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface TeamListItemProps { team: Team }
|
interface TeamListItemProps {
|
||||||
|
team: Team;
|
||||||
|
}
|
||||||
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
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>
|
<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">
|
||||||
</Stack>
|
{name}
|
||||||
</>
|
</Text>
|
||||||
})
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface TeamListProps {
|
interface TeamListProps {
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
@@ -27,30 +36,41 @@ interface TeamListProps {
|
|||||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = useCallback((teamId: string) =>
|
const handleClick = useCallback(
|
||||||
navigate({ to: `/teams/${teamId}` }), [navigate]);
|
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) return <List>
|
if (loading)
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
return (
|
||||||
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
|
<List>
|
||||||
>
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<Skeleton height={35} width={200} />
|
<ListItem
|
||||||
</ListItem>
|
key={`skeleton-${i}`}
|
||||||
))}
|
py="xs"
|
||||||
</List>
|
icon={<Skeleton height={40} width={40} />}
|
||||||
|
|
||||||
return <List>
|
|
||||||
{teams?.map((team) => (
|
|
||||||
<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} />
|
<Skeleton height={35} width={200} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
{teams?.map((team) => (
|
||||||
|
<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>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TeamList;
|
export default TeamList;
|
||||||
|
|||||||
@@ -3,20 +3,23 @@ import Avatar from "@/components/avatar";
|
|||||||
import { Team } from "../../types";
|
import { Team } from "../../types";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
team: Team;
|
name: string;
|
||||||
|
logo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Header = ({ team }: HeaderProps) => {
|
const Header = ({ name, logo }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
<Flex px="xl" w="100%" align="self-end" gap="md">
|
||||||
<Avatar radius='sm' name={team.name} size={125} />
|
<Avatar radius="sm" name={name} size={125} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
||||||
<Title ta='center' order={2}>{team.name}</Title>
|
<Title ta="center" order={2}>
|
||||||
|
{name}
|
||||||
|
</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
@@ -1,39 +1,43 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import Header from "./header";
|
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 SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||||
|
import { useTeam } from "../../queries";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
team: Team;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamProfile = ({ team }: ProfileProps) => {
|
const TeamProfile = ({ id }: ProfileProps) => {
|
||||||
console.log(team);
|
const { data: team } = useTeam(id);
|
||||||
|
if (!team) return <Text p="md">Team not found</Text>;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here</Text>
|
content: <Text p="md">Stats/Badges will go here</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
content: <Text p="md">Matches feed will go here</Text>
|
content: <Text p="md">Matches feed will go here</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Tournaments",
|
label: "Tournaments",
|
||||||
content: <>
|
content: (
|
||||||
<TournamentList tournaments={team.tournaments || []} />
|
<>
|
||||||
</>
|
<TournamentList tournaments={team.tournaments || []} />
|
||||||
}
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
return <>
|
return (
|
||||||
<Header team={team} />
|
<>
|
||||||
<Box m='sm' mt='lg'>
|
<Header name={team.name} logo={team.logo} />
|
||||||
<SwipeableTabs tabs={tabs} />
|
<Box m="sm" mt="lg">
|
||||||
</Box>
|
<SwipeableTabs tabs={tabs} />
|
||||||
</>;
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TeamProfile;
|
export default TeamProfile;
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
import { getTeam } from "./server";
|
import { getTeam } from "./server";
|
||||||
|
|
||||||
const teamKeys = {
|
export const teamKeys = {
|
||||||
details: (id: string) => ['teams', 'details', id] as const,
|
details: (id: string) => ['teams', 'details', id] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const teamQueries = {
|
export const teamQueries = {
|
||||||
details: (id: string) => queryOptions({
|
details: (id: string) => ({
|
||||||
queryKey: teamKeys.details(id),
|
queryKey: teamKeys.details(id),
|
||||||
queryFn: () => getTeam({ data: id }),
|
queryFn: () => getTeam({ data: id }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useTeam = (id: string) =>
|
||||||
|
useServerSuspenseQuery(teamQueries.details(id));
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from ".";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
|
||||||
export const getTeam = createServerFn()
|
export const getTeam = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) => {
|
.handler(async ({ data: teamId }) =>
|
||||||
logger.info('Getting team', teamId);
|
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||||
return await pbAdmin.getTeam(teamId);
|
);
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { Tournament } from "../tournaments/types";
|
import { Tournament } from "../tournaments/types";
|
||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
@@ -22,28 +22,36 @@ export interface Team {
|
|||||||
tournaments: Tournament[];
|
tournaments: Tournament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const teamInputSchema = z.object({
|
export const teamInputSchema = z
|
||||||
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
|
.object({
|
||||||
logo: z.file("Invalid logo").optional(),
|
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
|
||||||
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
|
logo: z.file("Invalid logo").optional(),
|
||||||
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
|
primary_color: z
|
||||||
song_id: z.string().max(255).optional(),
|
.string()
|
||||||
song_name: z.string().max(255).optional(),
|
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||||
song_artist: z.string().max(255).optional(),
|
.optional(),
|
||||||
song_album: z.string().max(255).optional(),
|
accent_color: z
|
||||||
song_year: z.number().int().optional(),
|
.string()
|
||||||
song_start: z.number().int().optional(),
|
.regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)")
|
||||||
song_end: z.number().int().optional(),
|
.optional(),
|
||||||
song_image_url: z.url("Invalid song image URL").optional(),
|
song_id: z.string().max(255).optional(),
|
||||||
}).refine(
|
song_name: z.string().max(255).optional(),
|
||||||
(data) => {
|
song_artist: z.string().max(255).optional(),
|
||||||
if (data.song_start && data.song_end) {
|
song_album: z.string().max(255).optional(),
|
||||||
return data.song_end > data.song_start;
|
song_year: z.number().int().optional(),
|
||||||
}
|
song_start: z.number().int().optional(),
|
||||||
return true;
|
song_end: z.number().int().optional(),
|
||||||
},
|
song_image_url: z.url("Invalid song image URL").optional(),
|
||||||
{ message: "Song end time must be after start time", path: ["song_end"] }
|
})
|
||||||
);
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.song_start && data.song_end) {
|
||||||
|
return data.song_end > data.song_start;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ message: "Song end time must be after start time", path: ["song_end"] }
|
||||||
|
);
|
||||||
|
|
||||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||||
export type TeamUpdateInput = Partial<TeamInput>;
|
export type TeamUpdateInput = Partial<TeamInput>;
|
||||||
|
|||||||
@@ -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 { TrashIcon } from "@phosphor-icons/react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState, useCallback, useMemo, memo } from "react";
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
import { tournamentQueries } from "../queries";
|
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||||
import useEnrollTeam from "../hooks/use-enroll-team";
|
import useEnrollTeam from "../hooks/use-enroll-team";
|
||||||
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
@@ -12,13 +18,17 @@ interface EditEnrolledTeamsProps {
|
|||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
interface TeamItemProps {
|
||||||
team: Team;
|
team: Team;
|
||||||
onUnenroll: (teamId: string) => void;
|
onUnenroll: (teamId: string) => void;
|
||||||
disabled: boolean;
|
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]
|
[team.players]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -26,9 +36,13 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
|||||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
<Avatar size={32} name={team.name} />
|
<Avatar size={32} name={team.name} />
|
||||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text fw={500} truncate>{team.name}</Text>
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
{playerNames && (
|
{playerNames && (
|
||||||
<Text size="xs" c="dimmed" truncate>{playerNames}</Text>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{playerNames}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -47,30 +61,43 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
|||||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const { data: tournament, isLoading: tournamentLoading } =
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
useQuery(tournamentQueries.details(tournamentId));
|
useTournament(tournamentId);
|
||||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
useQuery(tournamentQueries.unenrolled(tournamentId));
|
useUnenrolledTeams(tournamentId);
|
||||||
|
|
||||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
const autocompleteData = useMemo(() =>
|
const autocompleteData = useMemo(
|
||||||
unenrolledTeams.map((team: Team) => ({ value: team.id, label: team.name })),
|
() =>
|
||||||
|
unenrolledTeams.map((team: Team) => ({
|
||||||
|
value: team.id,
|
||||||
|
label: team.name,
|
||||||
|
})),
|
||||||
[unenrolledTeams]
|
[unenrolledTeams]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback((teamId: string) => {
|
const handleEnrollTeam = useCallback(
|
||||||
enrollTeam({ tournamentId, teamId }, {
|
(teamId: string) => {
|
||||||
onSuccess: () => {
|
enrollTeam(
|
||||||
setSearch("");
|
{ tournamentId, teamId },
|
||||||
}
|
{
|
||||||
});
|
onSuccess: () => {
|
||||||
}, [enrollTeam, tournamentId, setSearch]);
|
setSearch("");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[enrollTeam, tournamentId, setSearch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback((teamId: string) => {
|
const handleUnenrollTeam = useCallback(
|
||||||
unenrollTeam({ tournamentId, teamId });
|
(teamId: string) => {
|
||||||
}, [unenrollTeam, tournamentId]);
|
unenrollTeam({ tournamentId, teamId });
|
||||||
|
},
|
||||||
|
[unenrollTeam, tournamentId]
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = tournamentLoading || unenrolledLoading;
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
const enrolledTeams = tournament?.teams || [];
|
const enrolledTeams = tournament?.teams || [];
|
||||||
@@ -79,7 +106,9 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" w="100%">
|
<Stack gap="lg" w="100%">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text fw={600} size="sm">Add Team</Text>
|
<Text fw={600} size="sm">
|
||||||
|
Add Team
|
||||||
|
</Text>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
placeholder="Search for teams to enroll..."
|
placeholder="Search for teams to enroll..."
|
||||||
data={autocompleteData}
|
data={autocompleteData}
|
||||||
@@ -95,18 +124,26 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600} size="sm">Enrolled Teams</Text>
|
<Text fw={600} size="sm">
|
||||||
<Text size="xs" c="dimmed">{enrolledTeams.length} teams</Text>
|
Enrolled Teams
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{enrolledTeams.length} teams
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
) : !hasEnrolledTeams ? (
|
) : !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%">
|
<Stack gap="xs" w="100%">
|
||||||
{enrolledTeams.map((team: Team) => (
|
{enrolledTeams.map((team: Team) => (
|
||||||
<TeamItem
|
<TeamItem
|
||||||
key={team.id}
|
key={team.id}
|
||||||
team={team}
|
team={team}
|
||||||
onUnenroll={handleUnenrollTeam}
|
onUnenroll={handleUnenrollTeam}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useTournament } from "../queries";
|
||||||
import { tournamentQueries } from "../queries";
|
|
||||||
import { List } from "@mantine/core";
|
import { List } from "@mantine/core";
|
||||||
import ListButton from "@/components/list-button";
|
import ListButton from "@/components/list-button";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
import Sheet from "@/components/sheet/sheet";
|
||||||
import TournamentForm from "./tournament-form";
|
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 { useSheet } from "@/hooks/use-sheet";
|
||||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
|
|
||||||
@@ -13,26 +16,52 @@ interface ManageTournamentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||||
const { data: tournament } = useSuspenseQuery(
|
const { data: tournament } = useTournament(tournamentId);
|
||||||
tournamentQueries.details(tournamentId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!tournament) throw new Error("Tournament not found.");
|
if (!tournament) throw new Error("Tournament not found.");
|
||||||
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
|
const {
|
||||||
const { isOpen: editRulesOpened, open: openEditRules, close: closeEditRules } = useSheet();
|
isOpen: editTournamentOpened,
|
||||||
const { isOpen: editTeamsOpened, open: openEditTeams, close: closeEditTeams } = useSheet();
|
open: openEditTournament,
|
||||||
|
close: closeEditTournament,
|
||||||
|
} = useSheet();
|
||||||
|
const {
|
||||||
|
isOpen: editRulesOpened,
|
||||||
|
open: openEditRules,
|
||||||
|
close: closeEditRules,
|
||||||
|
} = useSheet();
|
||||||
|
const {
|
||||||
|
isOpen: editTeamsOpened,
|
||||||
|
open: openEditTeams,
|
||||||
|
close: closeEditTeams,
|
||||||
|
} = useSheet();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List>
|
<List>
|
||||||
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
|
<ListButton
|
||||||
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={openEditRules} />
|
label="Edit Tournament"
|
||||||
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={openEditTeams} />
|
Icon={HardDrivesIcon}
|
||||||
|
onClick={openEditTournament}
|
||||||
|
/>
|
||||||
|
<ListButton
|
||||||
|
label="Edit Rules"
|
||||||
|
Icon={PencilLineIcon}
|
||||||
|
onClick={openEditRules}
|
||||||
|
/>
|
||||||
|
<ListButton
|
||||||
|
label="Edit Enrolled Teams"
|
||||||
|
Icon={UsersThreeIcon}
|
||||||
|
onClick={openEditTeams}
|
||||||
|
/>
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Sheet title="Edit Tournament" opened={editTournamentOpened} onChange={closeEditTournament}>
|
<Sheet
|
||||||
<TournamentForm
|
title="Edit Tournament"
|
||||||
tournamentId={tournament.id}
|
opened={editTournamentOpened}
|
||||||
|
onChange={closeEditTournament}
|
||||||
|
>
|
||||||
|
<TournamentForm
|
||||||
|
tournamentId={tournament.id}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: tournament.name,
|
name: tournament.name,
|
||||||
location: tournament.location,
|
location: tournament.location,
|
||||||
@@ -40,20 +69,28 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
start_time: tournament.start_time,
|
start_time: tournament.start_time,
|
||||||
enroll_time: tournament.enroll_time,
|
enroll_time: tournament.enroll_time,
|
||||||
end_time: tournament.end_time,
|
end_time: tournament.end_time,
|
||||||
}}
|
}}
|
||||||
close={closeEditTournament}
|
close={closeEditTournament}
|
||||||
/>
|
/>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Sheet title="Edit Rules" opened={editRulesOpened} onChange={closeEditRules}>
|
<Sheet
|
||||||
|
title="Edit Rules"
|
||||||
|
opened={editRulesOpened}
|
||||||
|
onChange={closeEditRules}
|
||||||
|
>
|
||||||
<p>Test</p>
|
<p>Test</p>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Sheet title="Edit Enrolled Teams" opened={editTeamsOpened} onChange={closeEditTeams}>
|
<Sheet
|
||||||
|
title="Edit Enrolled Teams"
|
||||||
|
opened={editTeamsOpened}
|
||||||
|
onChange={closeEditTeams}
|
||||||
|
>
|
||||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ManageTournament;
|
export default ManageTournament;
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Box, Divider, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { Tournament } from "../../types";
|
import { useTournament } from "../../queries";
|
||||||
import { PreviewBracket } from "@/features/bracket/components/preview";
|
|
||||||
|
|
||||||
interface ProfileProps {
|
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 = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
|
|||||||
@@ -1,50 +1,73 @@
|
|||||||
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
|
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types"
|
import { Tournament } from "@/features/tournaments/types";
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react";
|
||||||
import { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
|
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
interface TournamentCardProps {
|
interface TournamentCardProps {
|
||||||
tournament: Tournament
|
tournament: Tournament;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||||
const navigate = useNavigate({ from: '/tournaments/$tournamentId' })
|
const navigate = useNavigate();
|
||||||
const displayDate = useMemo(() => {
|
const displayDate = useMemo(() => {
|
||||||
if (!tournament.start_time) return null
|
if (!tournament.start_time) return null;
|
||||||
const date = new Date(tournament.start_time)
|
const date = new Date(tournament.start_time);
|
||||||
if (isNaN(date.getTime())) return null
|
if (isNaN(date.getTime())) return null;
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
})
|
});
|
||||||
}, [tournament.start_time])
|
}, [tournament.start_time]);
|
||||||
|
|
||||||
return (
|
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>
|
<Stack>
|
||||||
<Flex align='center' gap='md'>
|
<Flex align="center" gap="md">
|
||||||
<Image
|
<Image
|
||||||
src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
|
|
||||||
maw={100}
|
maw={100}
|
||||||
mah={100}
|
mah={100}
|
||||||
fit='contain'
|
fit="contain"
|
||||||
|
src={
|
||||||
|
tournament.logo
|
||||||
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
alt={tournament.name}
|
alt={tournament.name}
|
||||||
fallbackSrc={"TODO"}
|
fallbackSrc={"TODO"}
|
||||||
/>
|
/>
|
||||||
<Stack ta='center' mx='auto' gap='0'>
|
<Stack ta="center" mx="auto" gap="0">
|
||||||
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>
|
<Text size="lg" fw={800}>
|
||||||
{displayDate && <Text c='dimmed' size='xs' fw={600}>{displayDate}</Text>}
|
{tournament.name} <CaretRightIcon size={12} weight="bold" />
|
||||||
<Stack gap={4} mt={4}>
|
</Text>
|
||||||
{ /* TODO: Add medalists when data is available */}
|
{displayDate && (
|
||||||
<Badge variant='dot' color='gold'>Longer Team Name Goes Here</Badge>
|
<Text c="dimmed" size="xs" fw={600}>
|
||||||
<Badge variant='dot' color='silver'>Some Team</Badge>
|
{displayDate}
|
||||||
<Badge variant='dot' color='orange'>Medium Team Name</Badge>
|
</Text>
|
||||||
</Stack>
|
)}
|
||||||
|
<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>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Stack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -2,106 +2,124 @@ import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
|
|||||||
import { useForm, UseFormInput } from "@mantine/form";
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
import { LinkIcon } from "@phosphor-icons/react";
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
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 { isNotEmpty } from "@mantine/form";
|
||||||
import useCreateTournament from "../hooks/use-create-tournament";
|
import useCreateTournament from "../hooks/use-create-tournament";
|
||||||
import useUpdateTournament from "../hooks/use-update-tournament";
|
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||||
import toast from '@/lib/sonner';
|
import toast from "@/lib/sonner";
|
||||||
import { logger } from "..";
|
import { logger } from "..";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { DateTimePicker } from "@mantine/dates";
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
interface TournamentFormProps {
|
interface TournamentFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
initialValues?: Partial<TournamentFormInput>;
|
initialValues?: Partial<TournamentInput>;
|
||||||
tournamentId?: string;
|
tournamentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormProps) => {
|
const TournamentForm = ({
|
||||||
|
close,
|
||||||
|
initialValues,
|
||||||
|
tournamentId,
|
||||||
|
}: TournamentFormProps) => {
|
||||||
const isEditMode = !!tournamentId;
|
const isEditMode = !!tournamentId;
|
||||||
|
|
||||||
const config: UseFormInput<TournamentFormInput> = {
|
const config: UseFormInput<TournamentInput> = {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: initialValues?.name || '',
|
name: initialValues?.name || "",
|
||||||
location: initialValues?.location || '',
|
location: initialValues?.location || "",
|
||||||
desc: initialValues?.desc || '',
|
desc: initialValues?.desc || "",
|
||||||
start_time: initialValues?.start_time || '',
|
start_time: initialValues?.start_time || "",
|
||||||
enroll_time: initialValues?.enroll_time || '',
|
enroll_time: initialValues?.enroll_time || "",
|
||||||
end_time: initialValues?.end_time || '',
|
end_time: initialValues?.end_time || "",
|
||||||
logo: undefined,
|
logo: undefined,
|
||||||
},
|
},
|
||||||
onSubmitPreventDefault: 'always',
|
onSubmitPreventDefault: "always",
|
||||||
validate: {
|
validate: {
|
||||||
name: isNotEmpty('Name is required'),
|
name: isNotEmpty("Name is required"),
|
||||||
location: isNotEmpty('Location is required'),
|
location: isNotEmpty("Location is required"),
|
||||||
start_time: isNotEmpty('Start time is required'),
|
start_time: isNotEmpty("Start time is required"),
|
||||||
enroll_time: isNotEmpty('Enrollment time is required'),
|
enroll_time: isNotEmpty("Enrollment time is required"),
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const form = useForm(config);
|
const form = useForm(config);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { mutate: createTournament, isPending: createPending } = useCreateTournament();
|
const { mutate: createTournament, isPending: createPending } =
|
||||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId || '');
|
useCreateTournament();
|
||||||
|
const { mutate: updateTournament, isPending: updatePending } =
|
||||||
|
useUpdateTournament(tournamentId || "");
|
||||||
|
|
||||||
const isPending = createPending || updatePending;
|
const isPending = createPending || updatePending;
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (values: TournamentFormInput) => {
|
const handleSubmit = useCallback(
|
||||||
const { logo, ...tournamentData } = values;
|
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';
|
|
||||||
|
|
||||||
mutation(tournamentData, {
|
|
||||||
onSuccess: async (tournament) => {
|
|
||||||
if (logo && tournament) {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('tournamentId', tournament.id);
|
|
||||||
formData.append('logo', logo);
|
|
||||||
|
|
||||||
const response = await fetch('/api/tournaments/upload-logo', {
|
const mutation = isEditMode ? updateTournament : createTournament;
|
||||||
method: 'POST',
|
const successMessage = isEditMode
|
||||||
body: formData,
|
? "Tournament updated successfully!"
|
||||||
});
|
: "Tournament created successfully!";
|
||||||
|
const errorMessage = isEditMode
|
||||||
|
? "Failed to update tournament"
|
||||||
|
: "Failed to create tournament";
|
||||||
|
|
||||||
if (!response.ok) {
|
mutation(tournamentData, {
|
||||||
const error = await response.json();
|
onSuccess: async (tournament) => {
|
||||||
throw new Error(error.error || 'Failed to upload logo');
|
if (logo && tournament) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("tournamentId", tournament.id);
|
||||||
|
formData.append("logo", logo);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: tournamentKeys.list });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: tournamentKeys.details(result.tournament!.id),
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(
|
||||||
|
tournamentKeys.details(result.tournament!.id),
|
||||||
|
result.tournament
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(successMessage);
|
||||||
|
} catch (error: any) {
|
||||||
|
const logoErrorMessage = isEditMode
|
||||||
|
? `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);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
|
|
||||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
|
|
||||||
queryClient.setQueryData(
|
|
||||||
tournamentQueries.details(result.tournament!.id).queryKey,
|
|
||||||
result.tournament
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
} catch (error: any) {
|
|
||||||
const logoErrorMessage = isEditMode
|
|
||||||
? `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);
|
|
||||||
}
|
}
|
||||||
} else {
|
close();
|
||||||
toast.success(successMessage);
|
},
|
||||||
}
|
onError: (error: any) => {
|
||||||
close();
|
toast.error(`${errorMessage}: ${error.message}`);
|
||||||
},
|
logger.error(
|
||||||
onError: (error: any) => {
|
`Tournament ${isEditMode ? "update" : "create"} error`,
|
||||||
toast.error(`${errorMessage}: ${error.message}`);
|
error
|
||||||
logger.error(`Tournament ${isEditMode ? 'update' : 'create'} error`, error);
|
);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}, [isEditMode, createTournament, updateTournament, queryClient]);
|
},
|
||||||
|
[isEditMode, createTournament, updateTournament, queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SlidePanel
|
<SlidePanel
|
||||||
@@ -115,83 +133,91 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
key={form.key('name')}
|
key={form.key("name")}
|
||||||
{...form.getInputProps('name')}
|
{...form.getInputProps("name")}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Location"
|
label="Location"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
key={form.key('location')}
|
key={form.key("location")}
|
||||||
{...form.getInputProps('location')}
|
{...form.getInputProps("location")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Description"
|
label="Description"
|
||||||
key={form.key('desc')}
|
key={form.key("desc")}
|
||||||
{...form.getInputProps('desc')}
|
{...form.getInputProps("desc")}
|
||||||
minRows={3}
|
minRows={3}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FileInput
|
<FileInput
|
||||||
key={form.key('logo')}
|
key={form.key("logo")}
|
||||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||||
label={isEditMode ? "Change Logo" : "Logo"}
|
label={isEditMode ? "Change Logo" : "Logo"}
|
||||||
leftSection={<LinkIcon size={16} />}
|
leftSection={<LinkIcon size={16} />}
|
||||||
{...form.getInputProps('logo')}
|
{...form.getInputProps("logo")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SlidePanelField
|
<SlidePanelField
|
||||||
key={form.key('start_time')}
|
key={form.key("start_time")}
|
||||||
{...form.getInputProps('start_time')}
|
{...form.getInputProps("start_time")}
|
||||||
Component={DateTimePicker}
|
Component={DateTimePicker}
|
||||||
title="Select Start Date"
|
title="Select Start Date"
|
||||||
label="Start Date"
|
label="Start Date"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
|
formatValue={(date) =>
|
||||||
weekday: 'short',
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
weekday: "short",
|
||||||
month: 'short',
|
year: "numeric",
|
||||||
day: 'numeric',
|
month: "short",
|
||||||
hour: 'numeric',
|
day: "numeric",
|
||||||
minute: 'numeric',
|
hour: "numeric",
|
||||||
hour12: true
|
minute: "numeric",
|
||||||
})}
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SlidePanelField
|
<SlidePanelField
|
||||||
key={form.key('enroll_time')}
|
key={form.key("enroll_time")}
|
||||||
{...form.getInputProps('enroll_time')}
|
{...form.getInputProps("enroll_time")}
|
||||||
Component={DateTimePicker}
|
Component={DateTimePicker}
|
||||||
title="Select Enrollment Due Date"
|
title="Select Enrollment Due Date"
|
||||||
label="Enrollment Due"
|
label="Enrollment Due"
|
||||||
withAsterisk
|
withAsterisk
|
||||||
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
|
formatValue={(date) =>
|
||||||
weekday: 'short',
|
new Date(date).toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
weekday: "short",
|
||||||
month: 'short',
|
year: "numeric",
|
||||||
day: 'numeric',
|
month: "short",
|
||||||
hour: 'numeric',
|
day: "numeric",
|
||||||
minute: 'numeric',
|
hour: "numeric",
|
||||||
hour12: true
|
minute: "numeric",
|
||||||
})}
|
hour12: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<SlidePanelField
|
<SlidePanelField
|
||||||
key={form.key('end_time')}
|
key={form.key("end_time")}
|
||||||
{...form.getInputProps('end_time')}
|
{...form.getInputProps("end_time")}
|
||||||
Component={DateTimePicker}
|
Component={DateTimePicker}
|
||||||
title="Select End Date"
|
title="Select End Date"
|
||||||
label="End Date (Optional)"
|
label="End Date (Optional)"
|
||||||
formatValue={(date) => date ? new Date(date).toLocaleDateString('en-US', {
|
formatValue={(date) =>
|
||||||
weekday: 'short',
|
date
|
||||||
year: 'numeric',
|
? new Date(date).toLocaleDateString("en-US", {
|
||||||
month: 'short',
|
weekday: "short",
|
||||||
day: 'numeric',
|
year: "numeric",
|
||||||
hour: 'numeric',
|
month: "short",
|
||||||
minute: 'numeric',
|
day: "numeric",
|
||||||
hour12: true
|
hour: "numeric",
|
||||||
}) : 'Not set'}
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
: "Not set"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -199,4 +225,4 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TournamentForm;
|
export default TournamentForm;
|
||||||
|
|||||||
@@ -1,35 +1,21 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { createTournament } from "@/features/tournaments/server";
|
import { createTournament } from "@/features/tournaments/server";
|
||||||
import toast from '@/lib/sonner';
|
|
||||||
import { TournamentInput } from "@/features/tournaments/types";
|
import { TournamentInput } from "@/features/tournaments/types";
|
||||||
import { logger } from "../";
|
import { logger } from "../";
|
||||||
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
const useCreateTournament = () => {
|
const useCreateTournament = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return useMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: TournamentInput) => createTournament({ data }),
|
mutationFn: (data: TournamentInput) => createTournament({ data }),
|
||||||
onMutate: (data) => {
|
onMutate: (data) => {
|
||||||
logger.info('Creating tournament', data);
|
logger.info('Creating tournament', data);
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
if (!data) {
|
navigate({ to: '/tournaments' });
|
||||||
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);
|
|
||||||
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!',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { enrollTeam } from "@/features/tournaments/server";
|
import { enrollTeam } from "@/features/tournaments/server";
|
||||||
import toast from '@/lib/sonner';
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
const useEnrollTeam = () => {
|
const useEnrollTeam = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
||||||
return enrollTeam({ data });
|
return enrollTeam({ data });
|
||||||
},
|
},
|
||||||
onSuccess: (data, { tournamentId }) => {
|
onSuccess: (data, { tournamentId }) => {
|
||||||
if (!data) {
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||||
toast.error('There was an issue enrolling. Please try again later.');
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||||
} 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!',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,19 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { unenrollTeam } from "@/features/tournaments/server";
|
import { unenrollTeam } from "@/features/tournaments/server";
|
||||||
import toast from '@/lib/sonner';
|
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
const useUnenrollTeam = () => {
|
const useUnenrollTeam = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
||||||
return unenrollTeam({ data });
|
return unenrollTeam({ data });
|
||||||
},
|
},
|
||||||
onSuccess: (data, { tournamentId }) => {
|
onSuccess: (_, { tournamentId }) => {
|
||||||
if (!data) {
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||||
toast.error('There was an issue unenrolling. Please try again later.');
|
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||||
} else {
|
|
||||||
// Invalidate both tournament details and unenrolled teams queries
|
|
||||||
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.',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { updateTournament } from "@/features/tournaments/server";
|
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) => {
|
const useUpdateTournament = (tournamentId: string) => {
|
||||||
return useMutation({
|
return useServerMutation({
|
||||||
mutationFn: (data: Partial<TournamentFormInput>) =>
|
mutationFn: (data: Partial<TournamentInput>) =>
|
||||||
updateTournament({ data: { id: tournamentId, updates: data } }),
|
updateTournament({ data: { id: tournamentId, updates: data } }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
|
||||||
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||||
|
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||||
|
|
||||||
const tournamentKeys = {
|
export const tournamentKeys = {
|
||||||
list: ['tournaments', 'list'] as const,
|
list: ['tournaments', 'list'] as const,
|
||||||
details: (id: string) => ['tournaments', 'details', id] as const,
|
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||||
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tournamentQueries = {
|
export const tournamentQueries = {
|
||||||
list: () => queryOptions({
|
list: () => ({
|
||||||
queryKey: tournamentKeys.list,
|
queryKey: tournamentKeys.list,
|
||||||
queryFn: listTournaments
|
queryFn: listTournaments
|
||||||
}),
|
}),
|
||||||
details: (id: string) => queryOptions({
|
details: (id: string) => ({
|
||||||
queryKey: tournamentKeys.details(id),
|
queryKey: tournamentKeys.details(id),
|
||||||
queryFn: () => getTournament({ data: id })
|
queryFn: () => getTournament({ data: id })
|
||||||
}),
|
}),
|
||||||
unenrolled: (id: string) => queryOptions({
|
unenrolled: (id: string) => ({
|
||||||
queryKey: tournamentKeys.unenrolled(id),
|
queryKey: tournamentKeys.unenrolled(id),
|
||||||
queryFn: () => getUnenrolledTeams({ data: 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));
|
||||||
|
|||||||
@@ -4,34 +4,20 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
|||||||
import { tournamentInputSchema } from "@/features/tournaments/types";
|
import { tournamentInputSchema } from "@/features/tournaments/types";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
|
||||||
export const listTournaments = createServerFn()
|
export const listTournaments = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async () => {
|
.handler(async () =>
|
||||||
try {
|
toServerResult(pbAdmin.listTournaments)
|
||||||
const result = await pbAdmin.listTournaments();
|
);
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching tournaments', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createTournament = createServerFn()
|
export const createTournament = createServerFn()
|
||||||
.validator(tournamentInputSchema)
|
.validator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) =>
|
||||||
try {
|
toServerResult(() => pbAdmin.createTournament(data))
|
||||||
logger.info('Creating tournament', data);
|
);
|
||||||
|
|
||||||
const tournament = await pbAdmin.createTournament(data);
|
|
||||||
|
|
||||||
return tournament;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating tournament', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateTournament = createServerFn()
|
export const updateTournament = createServerFn()
|
||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
@@ -39,26 +25,16 @@ export const updateTournament = createServerFn()
|
|||||||
updates: tournamentInputSchema.partial()
|
updates: tournamentInputSchema.partial()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) =>
|
||||||
try {
|
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getTournament = createServerFn()
|
export const getTournament = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) => {
|
.handler(async ({ data: tournamentId }) =>
|
||||||
logger.info('Getting tournament', tournamentId);
|
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
);
|
||||||
return tournament;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const enrollTeam = createServerFn()
|
export const enrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
@@ -66,8 +42,8 @@ export const enrollTeam = createServerFn()
|
|||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) => {
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
try {
|
toServerResult(async () => {
|
||||||
const userId = context.userAuthId;
|
const userId = context.userAuthId;
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
@@ -83,11 +59,8 @@ export const enrollTeam = createServerFn()
|
|||||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
||||||
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
||||||
return tournament;
|
return tournament;
|
||||||
} catch (error) {
|
})
|
||||||
logger.error('Error enrolling team', error);
|
);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unenrollTeam = createServerFn()
|
export const unenrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.validator(z.object({
|
||||||
@@ -95,22 +68,13 @@ export const unenrollTeam = createServerFn()
|
|||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, teamId }, context }) => {
|
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||||
try {
|
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getUnenrolledTeams = createServerFn()
|
export const getUnenrolledTeams = createServerFn()
|
||||||
.validator(z.string())
|
.validator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) => {
|
.handler(async ({ data: tournamentId }) =>
|
||||||
logger.info('Getting unenrolled teams', tournamentId);
|
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
||||||
const teams = await pbAdmin.getUnenrolledTeams(tournamentId);
|
);
|
||||||
return teams;
|
|
||||||
});
|
|
||||||
@@ -16,19 +16,6 @@ export interface Tournament {
|
|||||||
teams?: Team[];
|
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({
|
export const tournamentInputSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
location: z.string().optional(),
|
location: z.string().optional(),
|
||||||
@@ -40,6 +27,5 @@ export const tournamentInputSchema = z.object({
|
|||||||
end_time: z.string().optional(),
|
end_time: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
|
|
||||||
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
||||||
export type TournamentUpdateInput = Partial<TournamentInput>;
|
export type TournamentUpdateInput = Partial<TournamentInput>;
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ const useAppShellHeight = () => {
|
|||||||
const { header } = useRouterConfig();
|
const { header } = useRouterConfig();
|
||||||
|
|
||||||
const height = useMemo(() => {
|
const height = useMemo(() => {
|
||||||
const appShellBottomPadding = isMobile ? '70px' : '0px';
|
const appShellBottomPadding = isMobile ? "70px" : "0px";
|
||||||
const pageBottomPadding = '20px';
|
const pageBottomPadding = "20px";
|
||||||
const mobileNavbar = isMobile && !header.collapsed ? '4rem' : '0px';
|
const mobileNavbar = isMobile && !header.collapsed ? "4rem" : "0px";
|
||||||
const pullablePadding = '1.285rem';
|
const pullablePadding = "1.285rem";
|
||||||
|
|
||||||
return `calc(100dvh - var(--app-shell-header-height, 0px) - ${mobileNavbar} - ${pullablePadding} - ${appShellBottomPadding} - ${pageBottomPadding})`;
|
return `calc(100dvh - var(--app-shell-header-height, 0px) - ${mobileNavbar} - ${pullablePadding} - ${appShellBottomPadding} - ${pageBottomPadding})`;
|
||||||
}, [isMobile, header.collapsed]);
|
}, [isMobile, header.collapsed]);
|
||||||
|
|
||||||
return height;
|
return height;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default useAppShellHeight;
|
export default useAppShellHeight;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
|
||||||
export const useIsMobile = () => {
|
export const useIsMobile = () => {
|
||||||
const isMobile = useMediaQuery('(max-width: 48em)');
|
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||||
return isMobile;
|
return isMobile;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function useSheet(options: UseSheetOptions = {}) {
|
|||||||
|
|
||||||
const open = useCallback(() => setIsOpen(true), []);
|
const open = useCallback(() => setIsOpen(true), []);
|
||||||
const close = useCallback(() => setIsOpen(false), []);
|
const close = useCallback(() => setIsOpen(false), []);
|
||||||
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
|
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -19,6 +19,6 @@ export function useSheet(options: UseSheetOptions = {}) {
|
|||||||
props: {
|
props: {
|
||||||
opened: isOpen,
|
opened: isOpen,
|
||||||
onChange: setIsOpen,
|
onChange: setIsOpen,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type LogLevel = 'info' | 'success' | 'warn' | 'error';
|
type LogLevel = "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
interface LoggerOptions {
|
interface LoggerOptions {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -7,50 +7,38 @@ interface LoggerOptions {
|
|||||||
colors?: boolean;
|
colors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for performance - update once per second max
|
let cachedTimestamp = "";
|
||||||
let cachedTimestamp = '';
|
|
||||||
let lastTimestampUpdate = 0;
|
let lastTimestampUpdate = 0;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get formatted timestamp with caching for performance
|
|
||||||
* Format: MM/DD HH:mm:ss
|
|
||||||
*/
|
|
||||||
function getTimestamp(): string {
|
function getTimestamp(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Update cache only if more than 1 second has passed
|
|
||||||
if (now - lastTimestampUpdate > 1000) {
|
if (now - lastTimestampUpdate > 1000) {
|
||||||
const date = new Date(now);
|
const date = new Date(now);
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
|
cachedTimestamp = `${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||||
lastTimestampUpdate = now;
|
lastTimestampUpdate = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedTimestamp;
|
return cachedTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color and emoji for each log level
|
|
||||||
*/
|
|
||||||
function getLevelStyle(level: LogLevel): { color: string; label: string } {
|
function getLevelStyle(level: LogLevel): { color: string; label: string } {
|
||||||
const styles = {
|
const styles = {
|
||||||
info: { color: '#f5f5f5', label: 'INFO' },
|
info: { color: "#f5f5f5", label: "INFO" },
|
||||||
success: { color: '#10B981', label: 'SUCCESS' },
|
success: { color: "#10B981", label: "SUCCESS" },
|
||||||
warn: { color: '#F59E0B', label: 'WARN' },
|
warn: { color: "#F59E0B", label: "WARN" },
|
||||||
error: { color: '#EF4444', label: 'ERROR' },
|
error: { color: "#EF4444", label: "ERROR" },
|
||||||
};
|
};
|
||||||
|
|
||||||
return styles[level] || styles.info;
|
return styles[level] || styles.info;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Main logger class
|
|
||||||
*/
|
|
||||||
class Logger {
|
class Logger {
|
||||||
private options: LoggerOptions;
|
private options: LoggerOptions;
|
||||||
private context?: string;
|
private context?: string;
|
||||||
@@ -58,25 +46,21 @@ class Logger {
|
|||||||
constructor(context?: string, options: LoggerOptions = {}) {
|
constructor(context?: string, options: LoggerOptions = {}) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.options = {
|
this.options = {
|
||||||
enabled: process.env.NODE_ENV !== 'production',
|
enabled: process.env.NODE_ENV !== "production",
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
...options
|
...options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a child logger with a specific context
|
|
||||||
*/
|
|
||||||
child(context: string, options?: LoggerOptions): Logger {
|
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 });
|
return new Logger(childContext, { ...this.options, ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Core logging method
|
|
||||||
*/
|
|
||||||
private log(
|
private log(
|
||||||
level: LogLevel,
|
level: LogLevel,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -86,114 +70,92 @@ class Logger {
|
|||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
const style = getLevelStyle(level);
|
const style = getLevelStyle(level);
|
||||||
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : '';
|
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : "";
|
||||||
const context = this.context ? ` │ ${this.context}` : '';
|
const context = this.context ? ` │ ${this.context}` : "";
|
||||||
|
|
||||||
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
|
const groupLabel = `${timestamp}${style.label}${context} │ ${label}`;
|
||||||
|
|
||||||
const group = this.options.collapsed ? console.groupCollapsed : console.group;
|
const group = this.options.collapsed
|
||||||
|
? console.groupCollapsed
|
||||||
if (this.options.colors && typeof window !== 'undefined') {
|
: console.group;
|
||||||
group(
|
|
||||||
`%c${groupLabel}`,
|
if (this.options.colors && typeof window !== "undefined") {
|
||||||
`color: ${style.color}; font-weight: bold;`
|
group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`);
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
group(groupLabel);
|
group(groupLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data !== undefined) {
|
if (data !== undefined) {
|
||||||
console.log(data);
|
console.log(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rest.length > 0) {
|
if (rest.length > 0) {
|
||||||
for (const item of rest) {
|
for (const item of rest) {
|
||||||
console.log(item);
|
console.log(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* log level methods
|
|
||||||
*/
|
|
||||||
info(label: string, data?: any, ...rest: any[]): void {
|
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 {
|
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 {
|
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 {
|
error(label: string, data?: any, ...rest: any[]): void {
|
||||||
this.log('error', label, data, ...rest);
|
this.log("error", label, data, ...rest);
|
||||||
}
|
}
|
||||||
|
|
||||||
simple(message: string): void {
|
simple(message: string): void {
|
||||||
if (!this.options.enabled) return;
|
if (!this.options.enabled) return;
|
||||||
|
|
||||||
const style = getLevelStyle('info');
|
const style = getLevelStyle("info");
|
||||||
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : '';
|
const timestamp = this.options.showTimestamp ? `${getTimestamp()} │ ` : "";
|
||||||
const context = this.context ? ` │ ${this.context}` : '';
|
const context = this.context ? ` │ ${this.context}` : "";
|
||||||
|
|
||||||
const logMessage = `${timestamp}${style.label}${context} │ ${message}`;
|
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};`);
|
console.log(`%c${logMessage}`, `color: ${style.color};`);
|
||||||
} else {
|
} else {
|
||||||
console.log(logMessage);
|
console.log(logMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Measure performance of an operation
|
|
||||||
*/
|
|
||||||
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
async measure<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
const duration = (performance.now() - start).toFixed(2);
|
const duration = (performance.now() - start).toFixed(2);
|
||||||
|
|
||||||
this.success(`${label} completed`, {
|
this.success(`${label} completed`, {
|
||||||
duration: `${duration}ms`,
|
duration: `${duration}ms`,
|
||||||
result
|
result,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const duration = (performance.now() - start).toFixed(2);
|
const duration = (performance.now() - start).toFixed(2);
|
||||||
|
|
||||||
this.error(`${label} failed`, {
|
this.error(`${label} failed`, {
|
||||||
duration: `${duration}ms`,
|
duration: `${duration}ms`,
|
||||||
error
|
error,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw 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 const logger = new Logger();
|
||||||
|
|
||||||
export { Logger };
|
export { Logger };
|
||||||
|
|||||||
@@ -4,20 +4,25 @@ import { useMantineColorScheme } from "@mantine/core";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
|
const ColorSchemeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { metadata: { colorScheme } } = useAuth()
|
const {
|
||||||
|
metadata: { colorScheme },
|
||||||
|
} = useAuth();
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colorScheme) return;
|
if (!colorScheme) return;
|
||||||
|
|
||||||
setColorScheme(colorScheme);
|
setColorScheme(colorScheme);
|
||||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (themeColorMeta) {
|
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]);
|
}, [colorScheme]);
|
||||||
return children
|
return children;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ColorSchemeProvider;
|
export default ColorSchemeProvider;
|
||||||
|
|||||||
@@ -1,68 +1,71 @@
|
|||||||
import { useAuth } from "@/contexts/auth-context";
|
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 ColorSchemeProvider from "./color-scheme-provider";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const commonInputStyles = {
|
const commonInputStyles = {
|
||||||
label: {
|
label: {
|
||||||
padding: 5
|
padding: 5,
|
||||||
},
|
},
|
||||||
root: {
|
root: {
|
||||||
margin: '0'
|
margin: "0",
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
defaultRadius: 'sm',
|
defaultRadius: "sm",
|
||||||
components: {
|
components: {
|
||||||
TextInput: {
|
TextInput: {
|
||||||
styles: commonInputStyles
|
styles: commonInputStyles,
|
||||||
},
|
},
|
||||||
DateTimePicker: {
|
DateTimePicker: {
|
||||||
styles: commonInputStyles
|
styles: commonInputStyles,
|
||||||
},
|
},
|
||||||
Input: {
|
Input: {
|
||||||
styles: commonInputStyles
|
styles: commonInputStyles,
|
||||||
},
|
},
|
||||||
Select: {
|
Select: {
|
||||||
styles: commonInputStyles
|
styles: commonInputStyles,
|
||||||
},
|
},
|
||||||
Autocomplete: {
|
Autocomplete: {
|
||||||
styles: commonInputStyles
|
styles: commonInputStyles,
|
||||||
},
|
},
|
||||||
DateTiemPicker: {
|
DateTiemPicker: {
|
||||||
styles: {
|
styles: {
|
||||||
root: {
|
root: {
|
||||||
zIndex: 1000
|
zIndex: 1000,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
backgroundColor: 'red'
|
backgroundColor: "red",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { metadata } = useAuth()
|
const { metadata } = useAuth();
|
||||||
const [isHydrated, setIsHydrated] = useState(false)
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsHydrated(true)
|
setIsHydrated(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const colorScheme = isHydrated ? (metadata.colorScheme || 'auto') : 'auto'
|
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
|
||||||
const primaryColor = isHydrated ? (metadata.accentColor || 'blue') : 'blue'
|
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
||||||
|
|
||||||
return <MantineProviderCore
|
return (
|
||||||
defaultColorScheme={colorScheme}
|
<MantineProviderCore
|
||||||
theme={{ ...theme, primaryColor }}
|
defaultColorScheme={colorScheme}
|
||||||
>
|
theme={{ ...theme, primaryColor }}
|
||||||
<ColorSchemeProvider>
|
>
|
||||||
{children}
|
<ColorSchemeProvider>{children}</ColorSchemeProvider>
|
||||||
</ColorSchemeProvider>
|
</MantineProviderCore>
|
||||||
</MantineProviderCore>
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default MantineProvider;
|
export default MantineProvider;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase from "pocketbase";
|
||||||
import { createPlayersService } from './services/players';
|
import { createPlayersService } from "./services/players";
|
||||||
import { createTournamentsService } from './services/tournaments';
|
import { createTournamentsService } from "./services/tournaments";
|
||||||
import { createTeamsService } from './services/teams';
|
import { createTeamsService } from "./services/teams";
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
@@ -11,20 +11,20 @@ class PocketBaseAdminClient {
|
|||||||
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
this.pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = (url, options) => {
|
this.pb.beforeSend = (url, options) => {
|
||||||
options.cache = 'no-store';
|
options.cache = "no-store";
|
||||||
options.headers = {
|
options.headers = {
|
||||||
...options.headers,
|
...options.headers,
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
'Pragma': 'no-cache',
|
Pragma: "no-cache",
|
||||||
'Expires': '0'
|
Expires: "0",
|
||||||
};
|
};
|
||||||
|
|
||||||
return { url, options };
|
return { url, options };
|
||||||
};
|
};
|
||||||
this.pb.autoCancellation(false);
|
this.pb.autoCancellation(false);
|
||||||
|
|
||||||
this.authPromise = this.authenticate();
|
this.authPromise = this.authenticate();
|
||||||
|
|
||||||
this.authPromise.then(() => {
|
this.authPromise.then(() => {
|
||||||
Object.assign(this, createPlayersService(this.pb));
|
Object.assign(this, createPlayersService(this.pb));
|
||||||
Object.assign(this, createTeamsService(this.pb));
|
Object.assign(this, createTeamsService(this.pb));
|
||||||
@@ -33,18 +33,20 @@ class PocketBaseAdminClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async authenticate() {
|
private async authenticate() {
|
||||||
await this.pb.collection("_superusers").authWithPassword(
|
await this.pb
|
||||||
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
|
.collection("_superusers")
|
||||||
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
|
.authWithPassword(
|
||||||
);
|
import.meta.env.VITE_POCKETBASE_ADMIN_EMAIL!,
|
||||||
|
import.meta.env.VITE_POCKETBASE_ADMIN_PASSWORD!
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdminClient extends
|
interface AdminClient
|
||||||
PocketBaseAdminClient,
|
extends PocketBaseAdminClient,
|
||||||
ReturnType<typeof createPlayersService>,
|
ReturnType<typeof createPlayersService>,
|
||||||
ReturnType<typeof createTeamsService>,
|
ReturnType<typeof createTeamsService>,
|
||||||
ReturnType<typeof createTournamentsService> {
|
ReturnType<typeof createTournamentsService> {
|
||||||
authPromise: Promise<void>;
|
authPromise: Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,50 @@
|
|||||||
import type { Player, PlayerInput, PlayerUpdateInput } from '@/features/players/types';
|
import type {
|
||||||
import { transformPlayer } from '@/lib/pocketbase/util/transform-types';
|
Player,
|
||||||
import PocketBase from 'pocketbase';
|
PlayerInput,
|
||||||
|
PlayerUpdateInput,
|
||||||
|
} from "@/features/players/types";
|
||||||
|
import { transformPlayer } from "@/lib/pocketbase/util/transform-types";
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
export function createPlayersService(pb: PocketBase) {
|
export function createPlayersService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
||||||
const result = await pb.collection('players').getList<Player>(1, 1, {
|
const result = await pb.collection("players").getList<Player>(1, 1, {
|
||||||
filter: `auth_id = "${authId}"`
|
filter: `auth_id = "${authId}"`,
|
||||||
});
|
});
|
||||||
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
return result.items[0] ? transformPlayer(result.items[0]) : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPlayer(id: string): Promise<Player | null> {
|
async getPlayer(id: string): Promise<Player> {
|
||||||
const result = await pb.collection('players').getOne(id, {
|
const result = await pb.collection("players").getOne(id, {
|
||||||
expand: 'teams'
|
expand: "teams",
|
||||||
});
|
});
|
||||||
return transformPlayer(result);
|
return transformPlayer(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listPlayers(): Promise<Player[]> {
|
async listPlayers(): Promise<Player[]> {
|
||||||
const result = await pb.collection('players').getFullList<Player>({
|
const result = await pb.collection("players").getFullList<Player>({
|
||||||
fields: 'id,first_name,last_name'
|
fields: "id,first_name,last_name",
|
||||||
});
|
});
|
||||||
return result.map(transformPlayer);
|
return result.map(transformPlayer);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createPlayer(data: PlayerInput): Promise<Player> {
|
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);
|
return transformPlayer(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
async updatePlayer(id: string, data: PlayerUpdateInput): Promise<Player> {
|
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);
|
return transformPlayer(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUnassociatedPlayers(): Promise<Player[]> {
|
async getUnassociatedPlayers(): Promise<Player[]> {
|
||||||
const result = await pb.collection('players').getFullList<Player>({
|
const result = await pb.collection("players").getFullList<Player>({
|
||||||
filter: 'auth_id = ""',
|
filter: 'auth_id = ""',
|
||||||
fields: 'id,first_name,last_name'
|
fields: "id,first_name,last_name",
|
||||||
});
|
});
|
||||||
return result.map(transformPlayer);
|
return result.map(transformPlayer);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ import { Team } from "@/features/teams/types";
|
|||||||
export function createTeamsService(pb: PocketBase) {
|
export function createTeamsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTeam(id: string): Promise<Team | null> {
|
async getTeam(id: string): Promise<Team | null> {
|
||||||
try {
|
logger.info("PocketBase | Getting team", id);
|
||||||
logger.info('PocketBase | Getting team', id);
|
const result = await pb.collection("teams").getOne(id, {
|
||||||
const result = await pb.collection('teams').getOne(id, {
|
expand: "players, tournaments",
|
||||||
expand: 'players, tournaments'
|
});
|
||||||
});
|
return transformTeam(result);
|
||||||
return transformTeam(result);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,11 @@ import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
|||||||
export function createTournamentsService(pb: PocketBase) {
|
export function createTournamentsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTournament(id: string): Promise<Tournament | null> {
|
async getTournament(id: string): Promise<Tournament | null> {
|
||||||
try {
|
logger.info("PocketBase | Getting tournament", id);
|
||||||
logger.info("PocketBase | Getting tournament", id);
|
const result = await pb.collection("tournaments").getOne(id, {
|
||||||
const result = await pb.collection("tournaments").getOne(id, {
|
expand: "teams, teams.players",
|
||||||
expand: "teams, teams.players",
|
});
|
||||||
});
|
return transformTournament(result);
|
||||||
return transformTournament(result);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async listTournaments(): Promise<Tournament[]> {
|
async listTournaments(): Promise<Tournament[]> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
@@ -51,60 +47,67 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
tournamentId: string,
|
tournamentId: string,
|
||||||
teamId: string
|
teamId: string
|
||||||
): Promise<Tournament> {
|
): Promise<Tournament> {
|
||||||
const result = await pb.collection("tournaments").update<Tournament>(
|
const result = await pb
|
||||||
tournamentId,
|
.collection("tournaments")
|
||||||
{ "teams+": teamId },
|
.update<Tournament>(
|
||||||
{ expand: "teams, teams.players" }
|
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);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
async unenrollTeam(
|
async unenrollTeam(
|
||||||
tournamentId: string,
|
tournamentId: string,
|
||||||
teamId: string
|
teamId: string
|
||||||
): Promise<Tournament> {
|
): Promise<Tournament> {
|
||||||
const result = await pb.collection("tournaments").update<Tournament>(
|
const result = await pb
|
||||||
tournamentId,
|
.collection("tournaments")
|
||||||
{ "teams-": teamId },
|
.update<Tournament>(
|
||||||
{ expand: "teams, teams.players" }
|
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);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
|
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("PocketBase | Getting unenrolled teams for tournament", tournamentId);
|
logger.info(
|
||||||
const tournament = await pb.collection("tournaments").getOne(tournamentId, {
|
"PocketBase | Getting unenrolled teams for tournament",
|
||||||
fields: "teams"
|
tournamentId
|
||||||
});
|
);
|
||||||
|
const tournament = await pb
|
||||||
|
.collection("tournaments")
|
||||||
|
.getOne(tournamentId, {
|
||||||
|
fields: "teams",
|
||||||
|
});
|
||||||
|
|
||||||
const enrolledTeamIds = tournament.teams || [];
|
const enrolledTeamIds = tournament.teams || [];
|
||||||
if (enrolledTeamIds.length === 0) {
|
if (enrolledTeamIds.length === 0) {
|
||||||
const allTeams = await pb.collection("teams").getFullList({
|
const allTeams = await pb.collection("teams").getFullList({
|
||||||
expand: "players"
|
expand: "players",
|
||||||
});
|
});
|
||||||
return allTeams.map(transformTeam);
|
return allTeams.map(transformTeam);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = enrolledTeamIds
|
const filter = enrolledTeamIds
|
||||||
.map((teamId: string) => `id != "${teamId}"`)
|
.map((teamId: string) => `id != "${teamId}"`)
|
||||||
.join(" && ");
|
.join(" && ");
|
||||||
|
|
||||||
const availableTeams = await pb.collection("teams").getFullList({
|
const availableTeams = await pb.collection("teams").getFullList({
|
||||||
filter,
|
filter,
|
||||||
expand: "players"
|
expand: "players",
|
||||||
});
|
});
|
||||||
|
|
||||||
return availableTeams.map(transformTeam);
|
return availableTeams.map(transformTeam);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("PocketBase | Error getting unenrolled teams", error);
|
logger.error("PocketBase | Error getting unenrolled teams", error);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function transformTeam(record: any): Team {
|
|||||||
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformPlayer) ?? [];
|
?.map(transformPlayer) ?? [];
|
||||||
const tournaments =
|
const tournaments =
|
||||||
record.expand?.tournaments
|
record.expand?.tournaments
|
||||||
?.sort((a: Tournament, b: Tournament) =>
|
?.sort((a: Tournament, b: Tournament) =>
|
||||||
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||||
@@ -56,7 +56,7 @@ export function transformTeam(record: any): Team {
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
players,
|
players,
|
||||||
tournaments
|
tournaments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { toast as sonnerToast } from 'sonner';
|
import { toast as sonnerToast } from "sonner";
|
||||||
import { ToastProps } from './types';
|
import { ToastProps } from "./types";
|
||||||
import { Notification } from '@mantine/core';
|
import { Notification } from "@mantine/core";
|
||||||
import { ShieldCheckIcon, WarningCircleIcon } from '@phosphor-icons/react';
|
import { ShieldCheckIcon, WarningCircleIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
const makeToast = (toast: Omit<ToastProps, 'id'>) => {
|
const makeToast = (toast: Omit<ToastProps, "id">) => {
|
||||||
return sonnerToast.custom((id) => (
|
return sonnerToast.custom((id) => (
|
||||||
<Toast
|
<Toast
|
||||||
id={id}
|
id={id}
|
||||||
@@ -14,24 +14,33 @@ const makeToast = (toast: Omit<ToastProps, 'id'>) => {
|
|||||||
color={toast.color}
|
color={toast.color}
|
||||||
loading={!!toast.loading}
|
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) {
|
function error(toast: Omit<ToastProps, "id"> | string) {
|
||||||
const config = typeof toast === 'string' ? { description: toast } : toast;
|
const config = typeof toast === "string" ? { description: toast } : toast;
|
||||||
return makeToast({ ...config, icon: <ShieldCheckIcon color='lightgreen' size={48} weight='fill'/> });
|
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) {
|
function Toast(props: ToastProps) {
|
||||||
const { title, description, withCloseButton, icon, loading } = props;
|
const { title, description, withCloseButton, icon, loading } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Notification miw='md' color={'rgba(0,0,0,0)'} withBorder
|
<Notification
|
||||||
|
miw="md"
|
||||||
|
color={"rgba(0,0,0,0)"}
|
||||||
|
withBorder
|
||||||
withCloseButton={!!withCloseButton}
|
withCloseButton={!!withCloseButton}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
title={title}
|
title={title}
|
||||||
@@ -42,7 +51,7 @@ function Toast(props: ToastProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
success,
|
success,
|
||||||
error,
|
error,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SuperTokens from 'supertokens-web-js';
|
import SuperTokens from "supertokens-web-js";
|
||||||
import Session from 'supertokens-web-js/recipe/session';
|
import Session from "supertokens-web-js/recipe/session";
|
||||||
import Passwordless from 'supertokens-web-js/recipe/passwordless';
|
import Passwordless from "supertokens-web-js/recipe/passwordless";
|
||||||
import { appInfo } from './config';
|
import { appInfo } from "./config";
|
||||||
import { logger } from './';
|
import { logger } from "./";
|
||||||
|
|
||||||
export const frontendConfig = () => {
|
export const frontendConfig = () => {
|
||||||
return {
|
return {
|
||||||
@@ -12,27 +12,27 @@ export const frontendConfig = () => {
|
|||||||
Session.init({
|
Session.init({
|
||||||
tokenTransferMethod: "cookie",
|
tokenTransferMethod: "cookie",
|
||||||
sessionTokenBackendDomain: undefined,
|
sessionTokenBackendDomain: undefined,
|
||||||
|
|
||||||
preAPIHook: async (context) => {
|
preAPIHook: async (context) => {
|
||||||
context.requestInit.credentials = "include";
|
context.requestInit.credentials = "include";
|
||||||
return context;
|
return context;
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
export function ensureSuperTokensFrontend() {
|
export function ensureSuperTokensFrontend() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
SuperTokens.init(frontendConfig());
|
SuperTokens.init(frontendConfig());
|
||||||
initialized = true;
|
initialized = true;
|
||||||
logger.info("Initialized");
|
logger.info("Initialized");
|
||||||
|
|
||||||
Session.doesSessionExist().then(exists => {
|
Session.doesSessionExist().then((exists) => {
|
||||||
logger.info(`Session does${exists ? '' : 'NOT'} exist on load!`);
|
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||||
import { logger } from "../";
|
import { logger } from "../";
|
||||||
|
|
||||||
const init = () => (
|
const init = () =>
|
||||||
Passwordless.init({
|
Passwordless.init({
|
||||||
flowType: "USER_INPUT_CODE",
|
flowType: "USER_INPUT_CODE",
|
||||||
contactMethod: "PHONE",
|
contactMethod: "PHONE",
|
||||||
@@ -14,17 +14,17 @@ const init = () => (
|
|||||||
throw new Error("No user input code provided to sendSms");
|
throw new Error("No user input code provided to sendSms");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Sending Code',
|
logger.info(
|
||||||
'######################',
|
"Sending Code",
|
||||||
'## SuperTokens Code ##',
|
"######################",
|
||||||
|
"## SuperTokens Code ##",
|
||||||
`## ${userInputCode} ##`,
|
`## ${userInputCode} ##`,
|
||||||
'######################'
|
"######################"
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
)
|
|
||||||
|
|
||||||
export default { init };
|
export default { init };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useSession } from "@tanstack/react-start/server";
|
|||||||
import Passwordless from "supertokens-node/recipe/passwordless";
|
import Passwordless from "supertokens-node/recipe/passwordless";
|
||||||
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
import { sendVerifyCode, updateVerify } from "@/lib/twilio";
|
||||||
|
|
||||||
const init = () => (
|
const init = () =>
|
||||||
Passwordless.init({
|
Passwordless.init({
|
||||||
flowType: "USER_INPUT_CODE",
|
flowType: "USER_INPUT_CODE",
|
||||||
contactMethod: "PHONE",
|
contactMethod: "PHONE",
|
||||||
@@ -18,22 +18,24 @@ const init = () => (
|
|||||||
const sid = await sendVerifyCode(phoneNumber, userInputCode);
|
const sid = await sendVerifyCode(phoneNumber, userInputCode);
|
||||||
|
|
||||||
const session = await useSession({
|
const session = await useSession({
|
||||||
password: preAuthSessionId
|
password: preAuthSessionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.update({
|
await session.update({
|
||||||
twilioSid: sid
|
twilioSid: sid,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
override: {
|
override: {
|
||||||
functions: (originalImplementation) => {
|
functions: (originalImplementation) => {
|
||||||
return {
|
return {
|
||||||
...originalImplementation,
|
...originalImplementation,
|
||||||
consumeCode: async (input) => {
|
consumeCode: async (input) => {
|
||||||
const session = await useSession({ password: input.preAuthSessionId });
|
const session = await useSession({
|
||||||
|
password: input.preAuthSessionId,
|
||||||
|
});
|
||||||
const twilioSid = session?.data.twilioSid;
|
const twilioSid = session?.data.twilioSid;
|
||||||
|
|
||||||
if (!twilioSid) {
|
if (!twilioSid) {
|
||||||
@@ -46,20 +48,22 @@ const init = () => (
|
|||||||
await updateVerify(twilioSid);
|
await updateVerify(twilioSid);
|
||||||
await session.update({
|
await session.update({
|
||||||
twilioSid: undefined,
|
twilioSid: undefined,
|
||||||
userId: response?.user.id
|
userId: response?.user.id,
|
||||||
})
|
});
|
||||||
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
|
} else if (response.status === "INCORRECT_USER_INPUT_CODE_ERROR") {
|
||||||
if (response.failedCodeInputAttemptCount !== response.maximumCodeInputAttempts) {
|
if (
|
||||||
|
response.failedCodeInputAttemptCount !==
|
||||||
|
response.maximumCodeInputAttempts
|
||||||
|
) {
|
||||||
await updateVerify(twilioSid);
|
await updateVerify(twilioSid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
)
|
|
||||||
|
|
||||||
export default { init };
|
export default { init };
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
|
|||||||
const session = await getSessionForSSR(request);
|
const session = await getSessionForSSR(request);
|
||||||
|
|
||||||
if (session.hasToken) {
|
if (session.hasToken) {
|
||||||
|
if (session.accessTokenPayload?.sub === undefined || session.accessTokenPayload?.sessionHandle === undefined) {
|
||||||
|
return {
|
||||||
|
hasToken: true,
|
||||||
|
needsRefresh: true,
|
||||||
|
error: 'TRY_REFRESH_TOKEN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasToken: true,
|
hasToken: true,
|
||||||
accessTokenPayload: session.accessTokenPayload,
|
accessTokenPayload: session.accessTokenPayload,
|
||||||
@@ -36,13 +44,3 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
|
|||||||
throw error;
|
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;
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,30 @@ import { logger } from "./";
|
|||||||
|
|
||||||
export const backendConfig = (): TypeInput => {
|
export const backendConfig = (): TypeInput => {
|
||||||
return {
|
return {
|
||||||
framework: 'custom',
|
framework: "custom",
|
||||||
supertokens: {
|
supertokens: {
|
||||||
connectionURI: import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
|
connectionURI:
|
||||||
|
import.meta.env.VITE_SUPERTOKENS_URI || "https://try.supertokens.io",
|
||||||
},
|
},
|
||||||
appInfo,
|
appInfo,
|
||||||
recipeList: [
|
recipeList: [
|
||||||
PasswordlessDevelopmentMode.init(),
|
PasswordlessDevelopmentMode.init(),
|
||||||
Session.init({
|
Session.init({
|
||||||
cookieSameSite: "lax",
|
cookieSameSite: "lax",
|
||||||
cookieSecure: process.env.NODE_ENV === 'production',
|
cookieSecure: process.env.NODE_ENV === "production",
|
||||||
cookieDomain: process.env.NODE_ENV === 'production' ? ".example.com" : undefined,
|
cookieDomain:
|
||||||
antiCsrf: process.env.NODE_ENV === 'production' ? "VIA_TOKEN" : "NONE",
|
process.env.NODE_ENV === "production" ? ".example.com" : undefined,
|
||||||
|
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
||||||
|
|
||||||
// Debug only
|
// Debug only
|
||||||
exposeAccessTokenToFrontendInCookieBasedAuth: true,
|
exposeAccessTokenToFrontendInCookieBasedAuth: true,
|
||||||
}),
|
}),
|
||||||
Dashboard.init(),
|
Dashboard.init(),
|
||||||
UserRoles.init()
|
UserRoles.init(),
|
||||||
],
|
],
|
||||||
telemetry: process.env.NODE_ENV !== 'production',
|
telemetry: process.env.NODE_ENV !== "production",
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
export function ensureSuperTokensBackend() {
|
export function ensureSuperTokensBackend() {
|
||||||
|
|||||||
4
src/lib/tanstack-query/hooks/index.ts
Normal file
4
src/lib/tanstack-query/hooks/index.ts
Normal 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';
|
||||||
37
src/lib/tanstack-query/hooks/use-optimistic-mutation.ts
Normal file
37
src/lib/tanstack-query/hooks/use-optimistic-mutation.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
47
src/lib/tanstack-query/hooks/use-server-mutation.ts
Normal file
47
src/lib/tanstack-query/hooks/use-server-mutation.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
31
src/lib/tanstack-query/hooks/use-server-query.ts
Normal file
31
src/lib/tanstack-query/hooks/use-server-query.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
32
src/lib/tanstack-query/hooks/user-server-suspense-query.ts
Normal file
32
src/lib/tanstack-query/hooks/user-server-suspense-query.ts
Normal 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
Reference in New Issue
Block a user