This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

391
src/app/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,391 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createServerRootRoute } from '@tanstack/react-start/server'
import { Route as rootRouteImport } from './routes/__root'
import { Route as LogoutRouteImport } from './routes/logout'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as AuthedIndexRouteImport } from './routes/_authed/index'
import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
const rootServerRouteImport = createServerRootRoute()
const LogoutRoute = LogoutRouteImport.update({
id: '/logout',
path: '/logout',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedRoute = AuthedRouteImport.update({
id: '/_authed',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedIndexRoute = AuthedIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedSettingsRoute = AuthedSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminRoute = AuthedAdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTournamentsIndexRoute = AuthedTournamentsIndexRouteImport.update({
id: '/tournaments/',
path: '/tournaments/',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedTournamentsTournamentIdRoute =
AuthedTournamentsTournamentIdRouteImport.update({
id: '/tournaments/$tournamentId',
path: '/tournaments/$tournamentId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedTeamsTeamIdRoute = AuthedTeamsTeamIdRouteImport.update({
id: '/teams/$teamId',
path: '/teams/$teamId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedProfilePlayerIdRoute = AuthedProfilePlayerIdRouteImport.update({
id: '/profile/$playerId',
path: '/profile/$playerId',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
id: '/preview',
path: '/preview',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTestServerRoute = ApiTestServerRouteImport.update({
id: '/api/test',
path: '/api/test',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootServerRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/admin': typeof AuthedAdminRouteWithChildren
'/settings': typeof AuthedSettingsRoute
'/': typeof AuthedIndexRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/settings': typeof AuthedSettingsRoute
'/': typeof AuthedIndexRoute
'/admin/preview': typeof AuthedAdminPreviewRoute
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authed': typeof AuthedRouteWithChildren
'/login': typeof LoginRoute
'/logout': typeof LogoutRoute
'/_authed/admin': typeof AuthedAdminRouteWithChildren
'/_authed/settings': typeof AuthedSettingsRoute
'/_authed/': typeof AuthedIndexRoute
'/_authed/admin/preview': typeof AuthedAdminPreviewRoute
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/login'
| '/logout'
| '/admin'
| '/settings'
| '/'
| '/admin/preview'
| '/profile/$playerId'
| '/teams/$teamId'
| '/tournaments/$tournamentId'
| '/admin/'
| '/tournaments'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/logout'
| '/settings'
| '/'
| '/admin/preview'
| '/profile/$playerId'
| '/teams/$teamId'
| '/tournaments/$tournamentId'
| '/admin'
| '/tournaments'
id:
| '__root__'
| '/_authed'
| '/login'
| '/logout'
| '/_authed/admin'
| '/_authed/settings'
| '/_authed/'
| '/_authed/admin/preview'
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId'
| '/_authed/admin/'
| '/_authed/tournaments/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthedRoute: typeof AuthedRouteWithChildren
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
}
export interface FileServerRoutesByFullPath {
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
}
export interface FileServerRoutesByTo {
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
}
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths: '/api/test' | '/api/auth/$' | '/api/events/$'
fileServerRoutesByTo: FileServerRoutesByTo
to: '/api/test' | '/api/auth/$' | '/api/events/$'
id: '__root__' | '/api/test' | '/api/auth/$' | '/api/events/$'
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
ApiTestServerRoute: typeof ApiTestServerRoute
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/logout': {
id: '/logout'
path: '/logout'
fullPath: '/logout'
preLoaderRoute: typeof LogoutRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed': {
id: '/_authed'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthedRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/': {
id: '/_authed/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthedIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/settings': {
id: '/_authed/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthedSettingsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin': {
id: '/_authed/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AuthedAdminRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/tournaments/': {
id: '/_authed/tournaments/'
path: '/tournaments'
fullPath: '/tournaments'
preLoaderRoute: typeof AuthedTournamentsIndexRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/': {
id: '/_authed/admin/'
path: '/'
fullPath: '/admin/'
preLoaderRoute: typeof AuthedAdminIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/tournaments/$tournamentId': {
id: '/_authed/tournaments/$tournamentId'
path: '/tournaments/$tournamentId'
fullPath: '/tournaments/$tournamentId'
preLoaderRoute: typeof AuthedTournamentsTournamentIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/teams/$teamId': {
id: '/_authed/teams/$teamId'
path: '/teams/$teamId'
fullPath: '/teams/$teamId'
preLoaderRoute: typeof AuthedTeamsTeamIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/profile/$playerId': {
id: '/_authed/profile/$playerId'
path: '/profile/$playerId'
fullPath: '/profile/$playerId'
preLoaderRoute: typeof AuthedProfilePlayerIdRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/preview': {
id: '/_authed/admin/preview'
path: '/preview'
fullPath: '/admin/preview'
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath {
'/api/test': {
id: '/api/test'
path: '/api/test'
fullPath: '/api/test'
preLoaderRoute: typeof ApiTestServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
}
}
interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
}
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
AuthedAdminRouteChildren,
)
interface AuthedRouteChildren {
AuthedAdminRoute: typeof AuthedAdminRouteWithChildren
AuthedSettingsRoute: typeof AuthedSettingsRoute
AuthedIndexRoute: typeof AuthedIndexRoute
AuthedProfilePlayerIdRoute: typeof AuthedProfilePlayerIdRoute
AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminRoute: AuthedAdminRouteWithChildren,
AuthedSettingsRoute: AuthedSettingsRoute,
AuthedIndexRoute: AuthedIndexRoute,
AuthedProfilePlayerIdRoute: AuthedProfilePlayerIdRoute,
AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute,
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
}
const AuthedRouteWithChildren =
AuthedRoute._addFileChildren(AuthedRouteChildren)
const rootRouteChildren: RootRouteChildren = {
AuthedRoute: AuthedRouteWithChildren,
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiTestServerRoute: ApiTestServerRoute,
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
}
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

37
src/app/router.tsx Normal file
View File

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

107
src/app/routes/__root.tsx Normal file
View File

@@ -0,0 +1,107 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import {
HeadContent,
Navigate,
Outlet,
Scripts,
createRootRouteWithContext
} from '@tanstack/react-router'
import * as React from 'react'
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
import { type QueryClient } from '@tanstack/react-query'
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client'
import { AuthContextType, authQueryConfig } from '@/contexts/auth-context'
import Providers from '@/components/providers'
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
import { HeaderConfig } from '@/features/core/types/header-config';
export const Route = createRootRouteWithContext<{
queryClient: QueryClient,
auth: AuthContextType,
header: HeaderConfig,
refresh: { toRefresh: string[] }
}>()({
head: () => ({
meta: [
{
charSet: 'utf-8'
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, interactive-widget=overlays-content',
}
],
links: [
{
rel: 'apple-touch-icon',
sizes: '180x180',
href: '/apple-touch-icon.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
href: '/favicon-32x32.png',
},
{
rel: 'icon',
type: 'image/png',
sizes: '16x16',
href: '/favicon-16x16.png',
},
{ rel: 'manifest', href: '/site.webmanifest' },
{ rel: 'icon', href: '/favicon.ico' },
],
}),
errorComponent: (props) => {
return (
<RootDocument>
<DefaultCatchBoundary {...props} />
</RootDocument>
)
},
component: RootComponent,
notFoundComponent: () => <Navigate to="/" />,
beforeLoad: async ({ context }) => {
// I don't really like this. I wish there was some way before the router is rendered to useAuth() and pass context there.
// See: https://github.com/TanStack/router/discussions/3531
const auth = await context.queryClient.ensureQueryData(authQueryConfig)
return {
auth
};
}
})
function RootComponent() {
React.useEffect(() => {
ensureSuperTokensFrontend()
}, [])
return (
<RootDocument>
<Providers>
<Outlet />
</Providers>
</RootDocument>
)
}
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<head>
<HeadContent />
<ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" />
</head>
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<div className='app'>
{children}
</div>
<Scripts />
</body>
</html>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { createFileRoute } from "@tanstack/react-router"
import { Title } from "@mantine/core";
import Page from "@/components/page";
import { playerQueries } from "@/features/players/queries";
import { useQuery } from "@tanstack/react-query";
import PlayerList from "@/features/players/components/player-list";
export const Route = createFileRoute("/_authed/admin/")({
loader: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(playerQueries.list())
},
component: RouteComponent,
})
function RouteComponent() {
const { data: players, isLoading } = useQuery(playerQueries.list());
return <Page>
<Title order={2} mb='md'>Players</Title>
<PlayerList players={players!} loading={isLoading} />
</Page>
}

View File

@@ -0,0 +1,10 @@
import { PreviewBracketPage } from '@/features/bracket/components/bracket-page'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed/admin/preview')({
component: RouteComponent,
})
function RouteComponent() {
return <PreviewBracketPage />
}

View File

@@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import Page from "@/components/page";
import { TrophyIcon } from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import { tournamentQueries } from "@/features/tournaments/queries";
import { Box, Divider, Text } from "@mantine/core";
export const Route = createFileRoute("/_authed/")({
component: Home,
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(tournamentQueries.list());
},
});
function Home() {
return (
<Page noPadding>
<Box h='60vh' p="md">
<Text m='16vh' fw={500}>Some Content Here</Text>
</Box>
<Box>
<Text pl='md'>Quick Links</Text>
<Divider />
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
</Box>
</Page>
);
}

View File

@@ -0,0 +1,28 @@
import Page from "@/components/page";
import Profile from "@/features/players/components/profile";
import { playerQueries } from "@/features/players/queries";
import { redirect, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/profile/$playerId")({
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
if (!player) throw redirect({ to: '/' });
return {
player
}
},
loader: ({ params }) => ({
header: {
collapsed: true,
withBackButton: true
},
refresh: {
toRefresh: [playerQueries.details(params.playerId).queryKey],
}
}),
component: () => {
const { player } = Route.useRouteContext();
return <Page><Profile player={player} /></Page>
},
})

View File

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

View File

@@ -0,0 +1,26 @@
import Page from "@/components/page";
import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries";
import { redirect, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/teams/$teamId")({
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
if (!team) throw redirect({ to: '/' });
return { team }
},
loader: ({ params }) => ({
header: {
collapsed: true,
withBackButton: true
},
refresh: {
toRefresh: [teamQueries.details(params.teamId).queryKey],
}
}),
component: () => {
const { team } = Route.useRouteContext();
return <Page><TeamProfile team={team} /></Page>
},
})

View File

@@ -0,0 +1,67 @@
import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries';
import Page from '@/components/page'
import { useQuery } from '@tanstack/react-query';
import { Box, Button } from '@mantine/core';
import { useSheet } from '@/hooks/use-sheet';
import Sheet from '@/components/sheet/sheet';
import { Tournament } from '@/features/tournaments/types';
import { UsersIcon } from '@phosphor-icons/react';
import ListButton from '@/components/list-button';
import TeamList from '@/features/teams/components/team-list';
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
},
loader: ({ params }) => ({
header: {
collapsed: true,
withBackButton: true
},
refresh: {
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,
}
}),
component: RouteComponent,
})
function RouteComponent() {
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
const sheet = useSheet()
return <Page noPadding>
<Box mt='xl' p='md'>
<h3 style={{ marginTop: 0 }}>
{tournament?.name}
</h3>
<Button onClick={() => sheet.open()}>
View Teams
</Button>
</Box>
<ListButton
label='Teams'
onClick={() => sheet.open()}
Icon={UsersIcon}
/>
<Sheet
{...sheet.props}
title='Teams'
>
<TeamDrawer tournament={tournament!} />
</Sheet>
</Page>
}
const TeamDrawer = ({ tournament }: { tournament: Tournament }) => {
return (
<TeamList teams={tournament?.teams!} />
);
}

View File

@@ -0,0 +1,54 @@
import Page from '@/components/page'
import { Button, Stack } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
import { tournamentQueries } from '@/features/tournaments/queries'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/contexts/auth-context'
import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/sheet'
import CreateTournament from '@/features/admin/components/create-tournament'
import { PlusIcon } from '@phosphor-icons/react'
export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.list())
},
loader: () => ({
header: {
withBackButton: true,
title: 'Tournaments',
},
refresh: {
toRefresh: tournamentQueries.list().queryKey,
}
}),
component: RouteComponent,
})
function RouteComponent() {
const { data: tournaments } = useQuery(tournamentQueries.list());
const { roles } = useAuth();
const sheet = useSheet();
return <Page>
<Stack>
{
roles?.includes("Admin") ? (
<>
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
<Sheet {...sheet.props} title='Create Tournament'>
<CreateTournament close={sheet.close} />
</Sheet>
</>
) : null
}
{
tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))
}
</Stack>
</Page>
}

View File

@@ -0,0 +1,19 @@
// API file that handles all supertokens auth routes
import { createServerFileRoute } from '@tanstack/react-start/server';
import { handleAuthAPIRequest } from 'supertokens-node/custom'
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
ensureSuperTokensBackend();
// forwards all supertokens api methods to our API
const superTokensHandler = handleAuthAPIRequest();
const handleRequest = async ({ request }: {request: Request}) => superTokensHandler(request);
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
GET: handleRequest,
POST: handleRequest,
PUT: handleRequest,
DELETE: handleRequest,
PATCH: handleRequest,
OPTIONS: handleRequest,
HEAD: handleRequest,
})

View File

@@ -0,0 +1,67 @@
import { createServerFileRoute } from "@tanstack/react-start/server";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
GET: ({ request, context }) => {
logger.info('ServerEvents | New connection', (context as any)?.userAuthId);
const stream = new ReadableStream({
start(controller) {
// Send initial connection messages
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
// Listen for events and broadcast to all connections
const handleEvent = (event: ServerEvent) => {
logger.info('ServerEvents | Event received', event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
logger.error("ServerEvents | Error sending SSE message", error);
}
};
serverEvents.on("test", handleEvent);
// Keep alive ping every 30 seconds
const pingInterval = setInterval(() => {
try {
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
clearInterval(pingInterval);
controller.close();
}
}, 30000);
const cleanup = () => {
serverEvents.off("test", handleEvent);
clearInterval(pingInterval);
try {
logger.info('ServerEvents | Closing connection', (context as any)?.userAuthId);
controller.close();
} catch (e) {
logger.error('ServerEvents | Error closing controller', e);
}
};
request.signal?.addEventListener("abort", cleanup);
return cleanup;
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
});
},
});

View File

@@ -0,0 +1,9 @@
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!')
},
})

27
src/app/routes/login.tsx Normal file
View File

@@ -0,0 +1,27 @@
import LoginLayout from "@/features/login/components/layout";
import LoginFlow from "@/features/login/components/login-flow";
import { redirect, createFileRoute } from "@tanstack/react-router";
import z from "zod";
const loginSearchSchema = z.object({
stage: z.enum(['code', 'name']).optional(),
number: z.string().optional(),
callback: z.string().optional()
});
export const Route = createFileRoute("/login")({
validateSearch: loginSearchSchema,
beforeLoad: async ({ context }) => {
if (context.auth?.user) {
throw redirect({ to: '/' })
}
},
component: () => {
return (
<LoginLayout>
<LoginFlow />
</LoginLayout>
)
}
})

14
src/app/routes/logout.tsx Normal file
View File

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

2
src/app/tanstack-start.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
import '../../.tanstack-start/server-routes/routeTree.gen'

View File

@@ -0,0 +1,53 @@
import {
ErrorComponent,
Link,
rootRouteId,
useMatch,
useRouter,
} from '@tanstack/react-router'
import type { ErrorComponentProps } from '@tanstack/react-router'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter()
const isRoot = useMatch({
strict: false,
select: (state) => state.id === rootRouteId,
})
console.error('DefaultCatchBoundary Error:', error)
return (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
<ErrorComponent error={error} />
<div className="flex gap-2 items-center flex-wrap">
<button
onClick={() => {
router.invalidate()
}}
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
>
Try Again
</button>
{isRoot ? (
<Link
to="/"
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
>
Home
</Link>
) : (
<Link
to="/"
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
onClick={(e) => {
e.preventDefault()
window.history.back()
}}
>
Go Back
</Link>
)}
</div>
</div>
)
}

15
src/components/avatar.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Avatar as MantineAvatar, AvatarProps as MantineAvatarProps, Paper } from '@mantine/core';
interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'size'> {
name: string;
size?: number;
radius?: string | number;
}
const Avatar = ({ name, size = 35, radius = '100%', ...props }: AvatarProps) => {
return <Paper p={size / 20} radius={radius} withBorder>
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} {...props} />
</Paper>
}
export default Avatar;

View File

@@ -0,0 +1,27 @@
import { NavLink, Text } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
interface ListLinkProps {
label: string;
to: string;
Icon: Icon;
}
const ListLink = ({ label, to, Icon }: ListLinkProps) => {
const navigate = useNavigate();
return (
<NavLink
w='100%'
p='md'
component={'button'}
onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>}
leftSection={<Icon weight='bold' size={20} />}
rightSection={<CaretRightIcon size={20} />}
/>
)
}
export default ListLink;

15
src/components/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Container, ContainerProps } from "@mantine/core";
import useHeaderConfig from "@/features/core/hooks/use-header-config";
interface PageProps extends ContainerProps, React.PropsWithChildren {
noPadding?: boolean;
}
const Page = ({ children, noPadding, ...props }: PageProps) => {
const headerConfig = useHeaderConfig();
return <Container px={noPadding ? 0 : 'md'} pt={headerConfig.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
{children}
</Container>
}
export default Page;

View File

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

View File

@@ -0,0 +1,16 @@
import { AuthProvider } from "@/contexts/auth-context"
import MantineProvider from "@/lib/mantine/mantine-provider"
import { Toaster } from "sonner"
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<AuthProvider>
<MantineProvider>
<Toaster position='top-center' />
{children}
</MantineProvider>
</AuthProvider>
)
}
export default Providers;

View File

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

View File

@@ -0,0 +1 @@
export * from './sheet';

View File

@@ -0,0 +1,16 @@
import { Modal as MantineModal, Title } from "@mantine/core";
import { PropsWithChildren } from "react";
interface ModalProps extends PropsWithChildren {
title?: string;
opened: boolean;
onClose: () => void;
}
const Modal: React.FC<ModalProps> = ({ title, children, opened, onClose }) => (
<MantineModal opened={opened} onClose={onClose} title={<Title order={3}>{title}</Title>}>
{children}
</MantineModal>
)
export default Modal;

View File

@@ -0,0 +1,30 @@
import { PropsWithChildren, useCallback } from "react";
import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer";
import Modal from "./modal";
import { Box, ScrollArea } from "@mantine/core";
interface SheetProps extends PropsWithChildren {
title?: string;
opened: boolean;
onChange: (next: boolean) => void;
}
const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
const isMobile = useIsMobile();
const handleClose = useCallback(() => onChange(false), [onChange]);
const SheetComponent = isMobile ? Drawer : Modal;
return (
<SheetComponent title={title} opened={opened} onChange={onChange} onClose={handleClose}>
<ScrollArea style={{ flex: 1 }} scrollbarSize={8} scrollbars='y' type='scroll'>
<Box mah='70vh'>
{children}
</Box>
</ScrollArea>
</SheetComponent>
);
};
export default Sheet;

View File

@@ -0,0 +1,7 @@
import { SlidePanel } from './slide-panel';
export * from './slide-panel';
export * from './slide-panel-field';
export * from './slide-panel-context';
export default SlidePanel;

View File

@@ -0,0 +1,18 @@
import { ComponentType, createContext } from "react";
interface SlidePanelContextType {
openPanel: (config: PanelConfig) => void;
closePanel: () => void;
}
interface PanelConfig {
title: string;
Component: ComponentType<any>;
value: any;
onChange: (value: any) => void;
componentProps?: Record<string, any>;
}
const SlidePanelContext = createContext<SlidePanelContextType | null>(null);
export { SlidePanelContext, type SlidePanelContextType, type PanelConfig };

View File

@@ -0,0 +1,89 @@
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import { ComponentType, useContext } from "react";
import { SlidePanelContext } from "./slide-panel-context";
interface SlidePanelFieldProps {
key: string;
value?: any;
onChange?: (value: any) => void;
Component: ComponentType<any>;
title: string;
label?: string;
placeholder?: string;
formatValue?: (value: any) => string;
componentProps?: Record<string, any>;
withAsterisk?: boolean;
error?: string;
}
const SlidePanelField = ({
value,
onChange,
Component,
title,
label,
placeholder = "Select value",
withAsterisk = false,
formatValue,
componentProps,
error,
}: SlidePanelFieldProps) => {
const context = useContext(SlidePanelContext);
if (!context) {
throw new Error('SlidePanelField must be used within a SlidePanel');
}
const handleClick = () => {
if (!onChange) return;
context.openPanel({
title,
Component,
value,
onChange,
componentProps,
});
};
const displayValue = () => {
if (formatValue && value != null) {
return formatValue(value);
}
if (value != null) {
if (value instanceof Date) {
return value.toLocaleDateString();
}
return String(value);
}
return placeholder;
};
return (
<Box>
<UnstyledButton
onClick={handleClick}
p='sm'
style={{
width: '100%',
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
borderRadius: 'var(--mantine-radius-lg)',
backgroundColor: 'var(--mantine-color-body)',
textAlign: 'left',
}}
>
<Flex justify="space-between" align="center">
<Stack>
<Text size="sm" fw={500}>{label}{withAsterisk && <Text span size="sm" c='var(--mantine-color-error)' fw={500} ml={4}>*</Text>}</Text>
<Text size="sm" c='dimmed'>{displayValue()}</Text>
</Stack>
<CaretRightIcon size={24} weight='thin' style={{ marginRight: '12px' }} />
</Flex>
</UnstyledButton>
{error && <Text size="xs" c='var(--mantine-color-error)' fw={500} ml={4} mt={4}>{error}</Text>}
</Box>
);
};
export { SlidePanelField };

View File

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

View File

@@ -0,0 +1,20 @@
.drawerOverlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
z-index: 101;
}
.drawerContent {
z-index: 999;
background-color: var(--mantine-color-body);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
margin-top: 24px;
height: fit-content;
position: fixed;
bottom: 0;
left: 0;
right: 0;
outline: none;
}

View File

@@ -0,0 +1,73 @@
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from "react";
import { MantineColor, MantineColorScheme } from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server";
const queryKey = ['auth'];
export const authQueryConfig = {
queryKey,
queryFn: fetchMe
}
interface AuthData {
user: any;
metadata: { accentColor: MantineColor; colorScheme: MantineColorScheme };
roles: string[];
}
export const defaultAuthData: AuthData = {
user: undefined,
metadata: { accentColor: 'blue', colorScheme: 'auto' },
roles: [],
}
export interface AuthContextType extends AuthData {
set: ({ user, metadata, roles }: Partial<AuthContextType>) => void;
}
const AuthContext = createContext<AuthContextType>({
...defaultAuthData,
set: () => {},
});
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<AuthData>(authQueryConfig);
const set = useCallback((updates: Partial<AuthData>) => {
queryClient.setQueryData(queryKey, (oldData: AuthData | undefined) => {
const currentData = oldData || defaultAuthData;
return {
...currentData,
...updates,
metadata: updates.metadata
? { ...currentData.metadata, ...updates.metadata }
: currentData.metadata
};
});
}, [queryClient]);
if (isLoading) {
return <p>Loading...</p>
}
return (
<AuthContext
value={{
user: data?.user || defaultAuthData.user,
metadata: data?.metadata || defaultAuthData.metadata,
roles: data?.roles || defaultAuthData.roles,
set
}}>
{children}
</AuthContext>
)
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,118 @@
import { Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
import { TournamentFormInput } from "@/features/tournaments/types";
import { DateTimePicker } from "./date-time-picker";
import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament";
const CreateTournament = ({ close }: { close: () => void }) => {
const config: UseFormInput<TournamentFormInput> = {
initialValues: { // TODO : Remove fake initial values
name: 'Test Tournament',
location: 'Test Location',
desc: 'Test Description',
logo_url: 'https://en.wikipedia.org/wiki/Trophy#/media/File:1934_Melbourne_Cup,_National_Museum_of_Australia.jpg',
start_time: '2025-01-01T00:00:00Z',
enroll_time: '2025-01-01T00:00:00Z',
},
onSubmitPreventDefault: 'always',
validate: {
name: isNotEmpty('Name is required'),
location: isNotEmpty('Location is required'),
start_time: isNotEmpty('Start time is required'),
enroll_time: isNotEmpty('Enrollment time is required'),
}
}
const form = useForm(config);
const { mutate: createTournament, isPending } = useCreateTournament();
const handleSubmit = async (values: TournamentFormInput) => {
createTournament(values, {
onSuccess: () => {
close();
}
});
}
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText="Create Tournament"
cancelText="Cancel"
loading={isPending}
>
<Stack>
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
/>
<TextInput
label="Short Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
/>
<TextInput
key={form.key('logo_url')}
accept="image/*"
label="Logo"
leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo_url')}
/>
<SlidePanelField
key={form.key('start_time')}
{...form.getInputProps('start_time')}
Component={DateTimePicker}
title="Select Start Date"
label="Start Date"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
<SlidePanelField
key={form.key('enroll_time')}
{...form.getInputProps('enroll_time')}
Component={DateTimePicker}
title="Select Enrollment Due Date"
label="Enrollment Due"
withAsterisk
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
/>
</Stack>
</SlidePanel>
);
};
export default CreateTournament;

View File

@@ -0,0 +1,90 @@
import { DatePicker, TimeInput } from "@mantine/dates";
import { ActionIcon, Stack } from "@mantine/core";
import { useRef } from "react";
import { ClockIcon } from "@phosphor-icons/react";
interface DateTimePickerProps {
value: Date | null;
onChange: (date: string | null) => void;
label?: string;
[key: string]: any;
}
const DateTimePicker = ({ value, onChange, label, ...rest }: DateTimePickerProps) => {
const timeRef = useRef<HTMLInputElement>(null);
const currentDate = value ? new Date(value) : null;
const formatDate = (date: Date | null): string => {
if (!date) return "";
return date.toISOString().split('T')[0];
};
const formatTime = (date: Date | null): string => {
if (!date) return "";
return date.toTimeString().slice(0, 5);
};
const handleDateChange = (dateString: string | null) => {
if (!dateString) {
onChange('');
return;
}
const newDate = new Date(dateString + 'T00:00:00');
if (currentDate) {
newDate.setHours(currentDate.getHours());
newDate.setMinutes(currentDate.getMinutes());
}
onChange(newDate.toISOString());
};
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const timeValue = event.target.value;
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
if (isNaN(hours) || isNaN(minutes)) return;
const baseDate = currentDate || new Date();
const newDate = new Date(baseDate);
newDate.setHours(hours);
newDate.setMinutes(minutes);
newDate.setSeconds(0);
newDate.setMilliseconds(0);
onChange(newDate.toISOString());
};
return (
<Stack>
<DatePicker
size="md"
value={formatDate(currentDate)}
onChange={handleDateChange}
{...rest}
/>
<TimeInput
ref={timeRef}
label="Time"
size="md"
value={formatTime(currentDate)}
onChange={handleTimeChange}
rightSection={
<ActionIcon
variant="subtle"
color="gray"
onClick={() => timeRef.current?.showPicker()}
>
<ClockIcon size={16} />
</ActionIcon>
}
{...rest}
/>
</Stack>
);
};
export { DateTimePicker };

View File

@@ -0,0 +1,37 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { createTournament } from "@/features/tournaments/server";
import toast from '@/lib/sonner';
import { TournamentInput } from "@/features/tournaments/types";
import { logger } from "../";
const useCreateTournament = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: (data: TournamentInput) => createTournament({ data }),
onMutate: (data) => {
logger.info('Creating tournament', data);
},
onSuccess: (data) => {
if (!data) {
toast.error('There was an issue creating your tournament. Please try again later.');
logger.error('Error creating tournament', data);
} else {
toast.success('Tournament created successfully!');
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.');
}
},
});
};
export default useCreateTournament;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin');

View File

@@ -0,0 +1,412 @@
// Type definitions
interface TMatchSlot {}
interface Seed extends TMatchSlot {
seed: number;
}
interface TBD extends TMatchSlot {
parent: TMatchBase;
loser: boolean;
ifNecessary?: boolean;
}
interface TMatchBase {
lid: number; // local id
round: number;
order?: number | null;
}
interface TMatch extends TMatchBase {
home: Seed | TBD;
away: Seed | TBD;
reset?: boolean;
}
interface TBye extends TMatchBase {
home: Seed | TBD;
}
type MatchType = TMatch | TBye;
// Utility functions
function innerOuter<T>(ls: T[]): T[] {
if (ls.length === 2) return ls;
const size = Math.floor(ls.length / 4);
const innerPart = [ls.slice(size, 2 * size), ls.slice(2 * size, 3 * size)];
const outerPart = [ls.slice(0, size), ls.slice(3 * size)];
const inner = (part: T[][]): T[] => [part[0].pop()!, part[1].shift()!];
const outer = (part: T[][]): T[] => [part[0].shift()!, part[1].pop()!];
const quads: T[][] = Array(Math.floor(size / 2)).fill(null).map(() => []);
const push = (part: T[][], method: (p: T[][]) => T[], arr: T[]) => {
if (part[0].length && part[1].length) {
arr.push(...method(part));
}
};
for (let i = 0; i < Math.floor(size / 2); i++) {
push(outerPart, outer, quads[i]);
push(innerPart, inner, quads[i]);
push(outerPart, inner, quads[i]);
push(innerPart, outer, quads[i]);
}
const result: T[] = [];
for (let i = 0; i < quads.length; i++) {
const curr = i % 2 === 0 ? quads.shift()! : quads.pop()!;
result.push(...curr);
}
return result;
}
function reverseHalfShift<T>(ls: T[]): T[] {
const halfLength = Math.floor(ls.length / 2);
return [...ls.slice(-halfLength), ...ls.slice(0, -halfLength)];
}
export class BracketGenerator {
private _bracket: MatchType[][] = [];
private _losersBracket: MatchType[][] = [];
private _order: number = 0;
private _floatingLosers: TBD[] = [];
private _lid: number = 0;
private _matches: Map<number, MatchType> = new Map();
public n: number;
public doubleElim: boolean;
private _nearestPowerOf2: number;
private _m: number;
private _byes: number;
constructor(n: number, doubleElim: boolean = false) {
if (n < 8 || n > 64) {
throw new Error("The number of teams must be greater than or equal to 8 and less than or equal to 64");
}
this.n = n;
this.doubleElim = doubleElim;
this._nearestPowerOf2 = Math.pow(2, Math.ceil(Math.log2(n)));
this._m = this._nearestPowerOf2;
this._byes = this._m - n;
this._generateSingleElim();
}
private _makeMatch(round: number, home: Seed | TBD, away: Seed | TBD, order: number): TMatch {
const match: TMatch = {
lid: this._lid,
round: round,
home: home,
away: away,
order: order
};
this._matches.set(this._lid, match);
this._lid += 1;
return match;
}
private _makeBye(round: number, home: Seed | TBD): TBye {
const bye: TBye = {
lid: this._lid,
round: round,
home: home
};
this._matches.set(this._lid, bye);
this._lid += 1;
return bye;
}
private _makeTBD(parent: TMatchBase, loser: boolean = false): TBD {
return {
parent: parent,
loser: loser
};
}
private _makeSeed(seed: number): Seed {
return { seed: seed };
}
private _parseQuad(quad: MatchType[]): MatchType[] {
// Used to generate losers bracket by iterating over the first round of the bracket, 4 matches/byes at a time
const pop = (): TBye => this._makeBye(0, this._floatingLosers.pop()!);
const popAt = (i: number) => (): TBye => this._makeBye(0, this._floatingLosers.splice(i, 1)[0]);
const shift = (): TBye => this._makeBye(0, this._floatingLosers.shift()!);
const popShift = (): TMatch => this._makeMatch(0, this._floatingLosers.pop()!, this._floatingLosers.shift()!, this._orderIncrement());
const pairShift = (): TMatch => this._makeMatch(0, this._floatingLosers.shift()!, this._floatingLosers.shift()!, this._orderIncrement());
// Actions to perform based on number of byes in the winners bracket quad
const actions: { [key: number]: (() => MatchType)[] } = {
0: [pop, pairShift, pop, pairShift],
1: [pop, shift, pop, pairShift],
2: [pop, shift, pop, shift],
3: [popAt(-2), popShift],
4: [pop, pop]
};
// Count the number of byes in the quad
const b = quad.filter(m => 'home' in m && !('away' in m)).length;
const result = actions[b].map(action => action());
return result;
}
private _flattenRound(round: MatchType[], roundNumber: number = 0): MatchType[] {
// Check if all matches are byes
if (round.every(m => 'home' in m && !('away' in m))) {
const result: MatchType[] = [];
for (let i = 0; i < round.length; i += 2) {
result.push(this._makeMatch(
roundNumber,
(round[i] as TBye).home,
(round[i + 1] as TBye).home,
this._orderIncrement()
));
}
return result;
}
return round;
}
private _startsWithBringInRound(): boolean {
// Start at 8, first block of size 4 returns 0
let start = 8;
const blockSizes = [4, 5, 7, 9, 15, 17]; // Sizes of blocks
let result = 0; // First block returns 0
// Loop through predefined block sizes
for (const blockSize of blockSizes) {
const end = start + blockSize - 1;
if (start <= this.n && this.n <= end) {
return result === 0;
}
start = end + 1;
result = 1 - result; // Alternate between 0 and 1
}
return false;
}
private _generateStartingRounds(): void {
this._floatingLosers = [];
// Generate Pairings based on seeding
const seeds: (Seed | null)[] = [];
for (let i = 1; i <= this.n; i++) {
seeds.push(this._makeSeed(i));
}
for (let i = 0; i < this._byes; i++) {
seeds.push(null);
}
const pairings: [Seed | null, Seed | null][] = [];
const innerOuterResult = innerOuter(seeds);
for (let i = 0; i < innerOuterResult.length; i += 2) {
pairings.push([innerOuterResult[i], innerOuterResult[i + 1]]);
}
// First Round
let round: MatchType[] = [];
for (const [home, away] of pairings) {
if (away === null) {
round.push(this._makeBye(0, home!));
} else {
const match = this._makeMatch(0, home!, away, this._orderIncrement());
round.push(match);
this._floatingLosers.push(this._makeTBD(match, true));
}
}
this._bracket = [round];
// Second Round
const prev = round;
round = [];
const getSlot = (m: MatchType): Seed | TBD => {
return 'away' in m ? this._makeTBD(m) : (m as TBye).home;
};
const startOrder = this._orderIncrement();
const orderDelta = Math.abs(this._byes - (this._m / 4));
const orderSplit = [startOrder + orderDelta, startOrder];
for (let i = 0; i < prev.length; i += 2) {
const home = getSlot(prev[i]);
const away = getSlot(prev[i + 1]);
let order: number;
if ('parent' in away) {
order = orderSplit[0];
orderSplit[0] += 1;
} else {
order = orderSplit[1];
orderSplit[1] += 1;
}
const match = this._makeMatch(1, home, away, order);
round.push(match);
this._floatingLosers.push(this._makeTBD(match, true));
}
this._bracket.push(round);
this._order = orderSplit[0] - 1;
// Generate losers bracket if double elim
if (this.doubleElim) {
// Round one
this._floatingLosers = innerOuter(this._floatingLosers);
this._losersBracket = [];
let roundOne: MatchType[] = [];
for (let i = 0; i < prev.length; i += 4) {
roundOne.push(...this._parseQuad(prev.slice(i, i + 4)));
}
this._losersBracket.push(this._flattenRound(roundOne));
// Round two
const roundTwo: MatchType[] = [];
for (let i = 0; i < roundOne.length; i += 2) {
roundTwo.push(this._makeMatch(
1,
getSlot(roundOne[i]),
getSlot(roundOne[i + 1]),
this._orderIncrement()
));
}
this._losersBracket.push(roundTwo);
}
}
private _orderIncrement(): number {
this._order += 1;
return this._order;
}
private _generateBringInRound(roundNumber: number): void {
console.log('generating bring in round', roundNumber);
const bringIns = reverseHalfShift(this._floatingLosers);
this._floatingLosers = [];
const round: MatchType[] = [];
const prev = this._losersBracket[this._losersBracket.length - 1];
for (const match of prev) {
const bringIn = bringIns.pop()!;
const newMatch = this._makeMatch(
roundNumber,
bringIn,
this._makeTBD(match),
this._orderIncrement()
);
round.push(newMatch);
}
this._losersBracket.push(round);
}
private _generateLosersRound(roundNumber: number): void {
console.log('generating losers round', roundNumber);
const round: MatchType[] = [];
const prev = this._losersBracket[this._losersBracket.length - 1];
if (prev.length < 2) return;
for (let i = 0; i < prev.length; i += 2) {
const newMatch = this._makeMatch(
roundNumber,
this._makeTBD(prev[i]),
this._makeTBD(prev[i + 1]),
this._orderIncrement()
);
round.push(newMatch);
}
this._losersBracket.push(round);
}
private _generateSingleElim(): void {
this._generateStartingRounds();
let prev = this._bracket[this._bracket.length - 1];
const add = (
round: MatchType[],
prevSlot: TBD | null,
match: MatchType
): [MatchType[], TBD | null] => {
if (prevSlot === null) return [round, this._makeTBD(match)];
const newMatch = this._makeMatch(
this._bracket.length,
prevSlot,
this._makeTBD(match),
this._orderIncrement()
);
this._floatingLosers.push(this._makeTBD(newMatch, true));
return [[...round, newMatch], null];
};
while (prev.length > 1) {
let round: MatchType[] = [];
let prevSlot: TBD | null = null;
for (const match of prev) {
[round, prevSlot] = add(round, prevSlot, match);
}
this._bracket.push(round);
prev = round;
if (this.doubleElim) {
const r = this._losersBracket.length;
if (this._startsWithBringInRound()) {
this._generateBringInRound(r);
this._generateLosersRound(r + 1);
} else {
this._generateLosersRound(r);
this._generateBringInRound(r + 1);
}
}
}
// Grand Finals and bracket reset
if (this.doubleElim) {
const winnersFinal = this._bracket[this._bracket.length - 1][this._bracket[this._bracket.length - 1].length - 1];
const losersFinal = this._losersBracket[this._losersBracket.length - 1][this._losersBracket[this._losersBracket.length - 1].length - 1];
const grandFinal = this._makeMatch(
this._bracket.length,
this._makeTBD(winnersFinal),
this._makeTBD(losersFinal),
this._orderIncrement()
);
const resetMatch = this._makeMatch(
this._bracket.length + 1,
this._makeTBD(grandFinal),
this._makeTBD(grandFinal, true),
this._orderIncrement()
);
resetMatch.reset = true;
this._bracket.push([grandFinal], [resetMatch]);
}
}
// Public getters for accessing the brackets
get bracket(): MatchType[][] {
return this._bracket;
}
get losersBracket(): MatchType[][] {
return this._losersBracket;
}
get matches(): Map<number, MatchType> {
return this._matches;
}
}

View File

@@ -0,0 +1,146 @@
import { Text, Container, Flex, ScrollArea } from "@mantine/core";
import { SeedList } from "./seed-list";
import BracketView from "./bracket-view";
import { MutableRefObject, RefObject, useEffect, useRef, useState } from "react";
import { bracketQueries } from "../queries";
import { useQuery } from "@tanstack/react-query";
import { useDraggable } from "react-use-draggable-scroll";
import { ref } from "process";
import './styles.module.css';
import { useIsMobile } from "@/hooks/use-is-mobile";
import useAppShellHeight from "@/hooks/use-appshell-height";
interface Team {
id: string;
name: string;
}
interface BracketData {
n: number;
doubleElim: boolean;
matches: { [key: string]: any };
winnersBracket: number[][];
losersBracket: number[][];
}
export const PreviewBracketPage: React.FC = () => {
const isMobile = useIsMobile();
const height = useAppShellHeight();
const refDraggable = useRef<HTMLDivElement>(null);
const { events } = useDraggable(refDraggable as RefObject<HTMLDivElement>, { isMounted: !!refDraggable.current });
const teamCount = 20;
const { data, isLoading, error } = useQuery<BracketData>(bracketQueries.preview(teamCount));
// Create teams with proper structure
const [teams, setTeams] = useState<Team[]>(
Array.from({ length: teamCount }, (_, i) => ({
id: `team-${i + 1}`,
name: `Team ${i + 1}`
}))
);
const [seededWinnersBracket, setSeededWinnersBracket] = useState<any[][]>([]);
const [seededLosersBracket, setSeededLosersBracket] = useState<any[][]>([]);
useEffect(() => {
if (!data) return;
// Map match IDs to actual match objects with team names
const mapBracket = (bracketIds: number[][]) => {
return bracketIds.map(roundIds =>
roundIds.map(lid => {
const match = data.matches[lid];
if (!match) return null;
const mappedMatch = { ...match };
// Map home slot - handle both uppercase and lowercase type names
if (match.home?.type?.toLowerCase() === 'seed') {
mappedMatch.home = {
...match.home,
team: teams[match.home.seed - 1]
};
}
// Map away slot if it exists - handle both uppercase and lowercase type names
if (match.away?.type?.toLowerCase() === 'seed') {
mappedMatch.away = {
...match.away,
team: teams[match.away.seed - 1]
};
}
return mappedMatch;
}).filter(m => m !== null)
);
};
setSeededWinnersBracket(mapBracket(data.winnersBracket));
setSeededLosersBracket(mapBracket(data.losersBracket));
}, [teams, data]);
const handleSeedChange = (teamIndex: number, newSeedIndex: number) => {
const newTeams = [...teams];
const movingTeam = newTeams[teamIndex];
// Remove the team from its current position
newTeams.splice(teamIndex, 1);
// Insert it at the new position
newTeams.splice(newSeedIndex, 0, movingTeam);
setTeams(newTeams);
};
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error loading bracket</p>;
if (!data) return <p>No data available</p>;
return (
<Container p={0} w="100%" style={{ userSelect: "none" }}>
<Flex w="100%" justify="space-between" h='3rem'>
<Text fw={600} size="lg" mb={16}>
Preview Bracket ({data.n} teams, {data.doubleElim ? 'Double' : 'Single'} Elimination)
</Text>
</Flex>
<Flex w="100%" gap={24}>
<div style={{ minWidth: 250, display: 'none' }}>
<Text fw={600} pb={16}>
Seed Teams
</Text>
<SeedList teams={teams} onSeedChange={handleSeedChange} />
</div>
<ScrollArea
px='xs'
viewportRef={refDraggable}
viewportProps={events}
h={`calc(${height} - 4rem)`}
className="bracket-container"
styles={{
root: { overflow: "auto", flex: 1, gap: 24, display: 'flex', flexDirection: 'column' }
}}
>
<div>
<Text fw={600} size="md" mb={16}>
Winners Bracket
</Text>
<BracketView
bracket={seededWinnersBracket}
matches={data.matches}
/>
</div>
<div>
<Text fw={600} size="md" mb={16}>
Losers Bracket
</Text>
<BracketView
bracket={seededLosersBracket}
matches={data.matches}
/>
</div>
</ScrollArea>
</Flex>
</Container>
);
};

View File

@@ -0,0 +1,119 @@
import { ActionIcon, Card, Container, Flex, Text } from '@mantine/core';
import { PlayIcon } from '@phosphor-icons/react';
import React from 'react';
interface BracketViewProps {
bracket: any[][];
matches: { [key: string]: any };
onAnnounce?: (teamOne: any, teamTwo: any) => void;
}
const BracketView: React.FC<BracketViewProps> = ({ bracket, matches, onAnnounce }) => {
// Helper to check match type (handle both uppercase and lowercase)
const isMatchType = (type: string, expected: string) => {
return type?.toLowerCase() === expected.toLowerCase();
};
// Helper to check slot type (handle both uppercase and lowercase)
const isSlotType = (type: string, expected: string) => {
return type?.toLowerCase() === expected.toLowerCase();
};
// Helper to get parent match order number
const getParentMatchOrder = (parentId: number): number | string => {
const parentMatch = matches[parentId];
if (parentMatch && parentMatch.order !== null && parentMatch.order !== undefined) {
return parentMatch.order;
}
// If no order (like for byes), return the parentId with a different prefix
return `Match ${parentId}`;
};
return (
<Flex direction='row' gap={24} justify='left' pos='relative' p='xl'>
{bracket.map((round, roundIndex) => (
<Flex direction='column' key={roundIndex} gap={24} justify='space-around'>
{round.map((match, matchIndex) => {
if (!match) return null;
// Handle bye matches (no away slot) - check both 'TBye' and 'bye'
if (isMatchType(match.type, 'bye') || isMatchType(match.type, 'tbye')) {
return (
<Flex key={matchIndex}>
</Flex>
);
}
// Regular matches with both home and away
return (
<Flex direction='row' key={matchIndex} align='center' justify='end' gap={8}>
<Text c='dimmed' fw='bolder'>{match.order}</Text>
<Card withBorder pos='relative' w={200} style={{ overflow: 'visible' }}>
<Card.Section withBorder p={4}>
{isSlotType(match.home?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.home.seed}</Text>
{match.home.team && <Text size='xs'>{match.home.team.name}</Text>}
</>
)}
{isSlotType(match.home?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.home.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.home.parentId || match.home.parent)}
</Text>
)}
{!match.home && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
<Card.Section p={4} mb={-16}>
{isSlotType(match.away?.type, 'seed') && (
<>
<Text c='dimmed' size='xs'>Seed {match.away.seed}</Text>
{match.away.team && <Text size='xs'>{match.away.team.name}</Text>}
</>
)}
{isSlotType(match.away?.type, 'tbd') && (
<Text c='dimmed' size='xs'>
{match.away.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(match.away.parentId || match.away.parent)}
</Text>
)}
{!match.away && <Text c='dimmed' size='xs' fs='italic'>TBD</Text>}
</Card.Section>
{match.reset && (
<Text
pos='absolute'
top={-8}
left={8}
size='xs'
c='orange'
fw='bold'
>
IF NECESSARY
</Text>
)}
{onAnnounce && match.home?.team && match.away?.team && (
<ActionIcon
pos='absolute'
variant='filled'
color='green'
top={-20}
right={-12}
onClick={() => {
onAnnounce(match.home.team, match.away.team);
}}
bd='none'
style={{ boxShadow: 'none' }}
size='xs'
>
<PlayIcon size={12} />
</ActionIcon>
)}
</Card>
</Flex>
);
})}
</Flex>
))}
</Flex>
);
};
export default BracketView;

View File

@@ -0,0 +1,49 @@
import { Flex, Text, Select, Card } from '@mantine/core';
import React from 'react';
interface Team {
id: string;
name: string;
}
interface SeedListProps {
teams: Team[];
onSeedChange: (currentIndex: number, newIndex: number) => void;
}
export function SeedList({ teams, onSeedChange }: SeedListProps) {
const seedOptions = teams.map((_, index) => ({
value: index.toString(),
label: `Seed ${index + 1}`
}));
return (
<Flex direction='column' gap={8}>
{teams.map((team, index) => (
<Card key={team.id} withBorder p="xs">
<Flex align="center" gap="xs" justify="space-between">
<Flex align="center" gap="xs">
<Select
value={index.toString()}
data={seedOptions}
onChange={(value) => {
if (value !== null) {
const newIndex = parseInt(value);
if (newIndex !== index) {
onSeedChange(index, newIndex);
}
}
}}
size="xs"
w={100}
/>
<Text size="sm" fw={500}>
{team.name}
</Text>
</Flex>
</Flex>
</Card>
))}
</Flex>
);
}

View File

@@ -0,0 +1,35 @@
/* Hide scrollbars but keep functionality */
.bracket-container::-webkit-scrollbar {
display: none;
}
.bracket-container {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Cursor states for draggable area */
.bracket-container:active {
cursor: grabbing;
}
/* Smooth scrolling on mobile */
.bracket-container {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
/* Prevent text selection while dragging */
.bracket-container * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Optional: Add subtle shadows for depth on desktop */
@media (min-width: 768px) {
.bracket-container {
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
}
}

View File

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

View File

@@ -0,0 +1,30 @@
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { Logger } from "@/lib/logger";
import brackets from './utils';
const logger = new Logger("Bracket Generation")
// Transform the imported JSON to match the expected format
function transformBracketData(bracketData: any) {
return {
n: bracketData.config.teams,
doubleElim: bracketData.config.doubleElimination,
matches: bracketData.matches,
winnersBracket: bracketData.structure.winners,
losersBracket: bracketData.structure.losers
};
}
export const previewBracket = createServerFn()
.validator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => {
logger.info('Generating bracket', teams);
if (!Object.keys(brackets).includes(teams.toString()))
throw Error("Bracket not available")
// Transform the imported data to match expected format
return transformBracketData(brackets[teams]);
});

View File

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

View File

@@ -0,0 +1,31 @@
import { Outlet, useRouter } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
const AnimatedOutlet = () => {
const router = useRouter();
return (
<AnimatePresence mode="wait">
<motion.div
key={router.state.location.pathname}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{
type: 'tween',
duration: 0.3,
ease: 'easeInOut'
}}
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
export default AnimatedOutlet;

View File

@@ -0,0 +1,25 @@
import { Box } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
interface BackButtonProps {
offsetY: number;
}
const BackButton = ({ offsetY }: BackButtonProps) => {
const router = useRouter()
return (
<Box
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
onClick={() => router.history.back()}
pos='absolute'
left={{ base: 0, sm: 100, md: 200, lg: 300 }}
m={20}
>
<ArrowLeftIcon weight='bold' size={20} />
</Box>
);
}
export default BackButton;

View File

@@ -0,0 +1,26 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button";
import { useMemo } from "react";
interface HeaderProps extends HeaderConfig {
scrollPosition: { x: number, y: number };
}
const Header = ({ withBackButton, collapsed, title, scrollPosition }: HeaderProps) => {
const offsetY = useMemo(() => {
return collapsed ? scrollPosition.y : 0;
}, [collapsed, scrollPosition.y]);
return (
<>
{withBackButton && <BackButton offsetY={offsetY} />}
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>
</AppShell.Header>
</>
);
}
export default Header;

View File

@@ -0,0 +1,53 @@
import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react';
import Header from './header';
import Navbar from './navbar';
import useHeaderConfig from '../hooks/use-header-config';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const headerConfig = useHeaderConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
return (
<AppShell
id='app-shell'
layout='alt'
header={{ height: 60, collapsed: headerConfig.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { mobile: true },
}}
aside={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { desktop: false, mobile: true }
}}
pos='relative'
h='100dvh'
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header scrollPosition={scrollPosition} {...headerConfig} />
<AppShell.Main
pos='relative'
h='100%'
mah='100%'
pb={{ base: 70, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
style={{ transition: 'none' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
{children}
</Pullable>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
</AppShell>
);
}
export default Layout;

View File

@@ -0,0 +1 @@
export * from './nav-link';

View File

@@ -0,0 +1,28 @@
import { Flex, Box, Text } from "@mantine/core";
import { Link, useRouterState } from "@tanstack/react-router";
import styles from './styles.module.css';
import { Icon } from "@phosphor-icons/react";
import { useMemo } from "react";
interface NavLinkProps {
href: string;
label: string;
Icon: Icon;
}
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
const router = useRouterState();
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
return (
<Box component={Link} to={href}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
>
<Flex direction={{ base: 'column', md: 'row' }} align='center' gap={{ base: 0, md: 'xs' }}>
<Icon weight={isActive ? 'fill' : 'regular'} size={28} style={{ color: isActive ? 'var(--mantine-primary-color-filled)' : undefined }} />
<Text visibleFrom='md' ta='center' size='md' fw={isActive ? 800 : 500} c={isActive ? 'var(--mantine-primary-color-filled)' : undefined}>{label}</Text>
</Flex>
</Box>
)
}

View File

@@ -0,0 +1,6 @@
.navLinkBox {
text-decoration: none;
border-radius: var(--mantine-radius-md);
color: unset;
width: fit-content;
}

View File

@@ -0,0 +1,39 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const links = useLinks(user?.id, roles);
console.log('rendered')
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Group>
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar>
}
export default Navbar;

View File

@@ -0,0 +1,124 @@
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
import useAppShellHeight from "@/hooks/use-appshell-height";
import useRefreshConfig from "@/features/core/hooks/use-refresh-config";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
const THRESHOLD = 80;
interface PullableProps extends PropsWithChildren {
scrollPosition: { x: number, y: number };
onScrollPositionChange: (position: { x: number, y: number }) => void;
}
/**
* Pullable is a component that allows the user to pull down to refresh the page
* TODO: Need to figure out why it isn't disabled when onRefresh is undefined
*/
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
const { toRefresh } = useRefreshConfig();
const queryClient = useQueryClient();
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
if (toRefresh.length > 0) {
// TODO: Remove this after testing - or does the delay help ux?
await new Promise(resolve => setTimeout(resolve, 1000));
await queryClient.refetchQueries({ queryKey: toRefresh, exact: true});
}
setIsRefreshing(false);
}, [toRefresh]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
onTrigger();
}
}, [scrollY, isRefreshing, onTrigger]);
const iconOpacity = useMemo(() => {
if (isRefreshing) return 1;
if (toRefresh.length === 0) return 0;
const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
const min = 5;
const max = THRESHOLD;
const range = max - min;
return (clampedValue - min) / range;
}, [scrollY, isRefreshing])
useEffect(() => {
const scrollWrapper = document.getElementById('scroll-wrapper');
if (scrollWrapper) {
scrollWrapper.addEventListener('touchstart', () => {
setScrolling(true);
});
scrollWrapper.addEventListener('touchend', () => {
setScrolling(false);
});
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const ac = new AbortController();
const options = {
passive: true,
signal: ac.signal
};
window.addEventListener('touchstart', () => setScrolling(true), options);
window.addEventListener('touchend', () => setScrolling(false), options);
return () => void ac.abort();
}, []);
return (
<>
<Flex
pos='absolute'
justify='center'
align='center'
w='100%'
display={scrollY > 20 || isRefreshing ? 'flex' : 'none'}
opacity={iconOpacity}
style={{ zIndex: 10 }}
>
<SpinnerIcon
weight="bold"
size={iconOpacity * 28}
color='var(--mantine-color-dimmed)'
style={{
marginTop: 8,
transform: iconOpacity === 1 ? undefined : `rotate(${iconOpacity * 360}deg)`,
animation: iconOpacity === 1 ? 'spin 1s linear infinite' : undefined,
}}
/>
</Flex>
<ScrollArea
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
>
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children}
</Box>
</ScrollArea>
</>
)
}
export default Pullable;

View File

@@ -0,0 +1,27 @@
import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'Starter App',
withBackButton: false,
collapsed: false,
}
const useHeaderConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
isMatch(match, 'loaderData.header'),
)
const config = matchesWithHeader.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.header,
}
}, defaultHeaderConfig) as HeaderConfig;
return config;
}
export default useHeaderConfig;

View File

@@ -0,0 +1,38 @@
import { GearIcon, HouseIcon, QuestionIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: number, roles: string[]) =>
useMemo(() => {
const links = [
{
label: 'Home',
href: '/',
Icon: HouseIcon
},
{
label: 'Tournaments',
href: '/tournaments',
Icon: TrophyIcon
},
{
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
},
{
label: 'Settings',
href: '/settings',
Icon: GearIcon
}
]
if (roles.includes('Admin')) {
links.push({
label: 'Admin',
href: '/admin',
Icon: ShieldIcon
})
}
return links;
}, [userId, roles]);

View File

@@ -0,0 +1,24 @@
import { isMatch, useMatches } from "@tanstack/react-router";
export const defaultRefreshConfig: { toRefresh: string[] } = {
toRefresh: [],
}
const useRefreshConfig = () => {
const matches = useMatches();
const matchesWithRefresh = matches.filter((match) =>
isMatch(match, 'loaderData.refresh'),
)
const config = matchesWithRefresh.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.refresh,
}
}, defaultRefreshConfig) as { toRefresh: string[] };
return config;
}
export default useRefreshConfig;

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
const eventListerOptions = {
passive: true,
};
const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0,
height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
});
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
useEffect(() => {
if (!windowExists) return;
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
return () => {
window.visualViewport?.removeEventListener('resize', setSize);
}
}, []);
return windowSize;
}
export default useVisualViewportSize;

View File

@@ -0,0 +1,7 @@
interface HeaderConfig {
title?: string;
withBackButton?: boolean;
collapsed?: boolean;
}
export type { HeaderConfig };

View File

@@ -0,0 +1,9 @@
import { HeaderConfig } from "./header-config";
interface RouteConfig {
header?: HeaderConfig;
refreshQueryKeys?: string[];
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | number;
}
export type { RouteConfig };

View File

@@ -0,0 +1,11 @@
import {
MantineColorSchemeManager,
} from '@mantine/core';
export const fakeColorSchemeManager: MantineColorSchemeManager = {
get: (defaultValue) => defaultValue,
set: (value) => { },
subscribe: (onUpdate) => { },
unsubscribe: () => { },
clear: () => { },
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Flex, PinInput, Title, Text, Stack, LoadingOverlay } from '@mantine/core';
import useConsumeCode from '../hooks/use-consume-code';
import { useSearch } from '@tanstack/react-router';
const CodePrompt = () => {
const { number } = useSearch({ from: '/login' });
const [isWrong, setIsWrong] = useState(false);
const [code, setCode] = useState('');
const handleWrongCode = () => setIsWrong(true);
const { mutate: consumeCode, isPending } = useConsumeCode(handleWrongCode);
const handleChange = (value: string) => {
if (value.length === 0) return;
if (isWrong) setIsWrong(false);
setCode(value);
if (value.length === 6) {
consumeCode(value);
}
}
return (
<Flex direction="column" p={10} w='max-content' m='auto'>
<Title order={4}>Enter Verification Code</Title>
<Text size='xs'c="dimmed" mb={5}>A code was sent to +1 ({number?.slice(0, 3)}) {number?.slice(3, 6)}-{number?.slice(6)}</Text>
<Stack justify='center' p={10} gap={2} pos='relative'>
<PinInput aria-label="One time code"
value={code}
error={isWrong}
onChange={handleChange}
autoFocus={true}
oneTimeCode
length={6}
disabled={isPending}
type='number'
/>
<LoadingOverlay visible={isPending} overlayProps={{ blur: 0.375, radius: 'md', backgroundOpacity: 0.35 }} />
{isWrong && <Text c='red' size='xs'>Incorrect code</Text>}
</Stack>
</Flex>
)
};
export default CodePrompt;

View File

@@ -0,0 +1,23 @@
import { Alert } from "@mantine/core";
import { Info } from "@phosphor-icons/react";
import { Transition } from "@mantine/core";
import { useMemo } from "react";
const Error = ({ error }: { error?: string }) => {
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
return (
<Transition
mounted={show}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
)}
</Transition>
)
}
export default Error;

View File

@@ -0,0 +1,45 @@
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const isMobile = useMediaQuery(`(max-width: ${em(450)})`);
const visualViewport = useVisualViewportSize();
const viewport = useViewportSize();
return (
<AppShell>
<AppShell.Main h='100%' style={{ overflow: 'scroll' }}>
<Flex
w={isMobile ? '100vw' : em(450)}
justify='center'
align='center'
h='auto'
direction='column'
gap='md'
mx='auto'
pt={viewport.height === visualViewport.height ? '5rem' : '12.5rem'}
style={{ transition: 'padding-top 0.1s ease' }}
>
<Paper
shadow='none'
p='md'
w='100%'
maw='375px'
radius='md'
>
<Stack align='center' gap='xs' mb='md'>
<TrophyIcon size={150} />
<Title order={2} ta='center'>Welcome to FLXN</Title>
</Stack>
{children}
</Paper>
</Flex>
</AppShell.Main>
</AppShell>
);
};
export default Layout;

View File

@@ -0,0 +1,23 @@
import { Center, Loader } from "@mantine/core";
import PhonePrompt from "./phone-prompt";
import CodePrompt from "./code-prompt";
import { useSearch } from "@tanstack/react-router";
import PlayerPrompt from "./player-prompt";
const LoginFlow = () => {
const { stage } = useSearch({ from: '/login' });
if (!stage) {
return <PhonePrompt />;
} else if (stage === 'code') {
return <CodePrompt />;
} else if (stage === 'name') {
return <PlayerPrompt />;
}
return <Center>
<Loader color="blue" size="xl" type="dots" />
</Center>;
};
export default LoginFlow;

View File

@@ -0,0 +1,12 @@
import Layout from './layout';
import LoginFlow from './login-flow';
const LoginPage = () => {
return (
<Layout>
<LoginFlow />
</Layout>
);
};
export default LoginPage;

View File

@@ -0,0 +1,49 @@
import { Button, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import useCreateUser from "../hooks/use-create-user";
const NamePrompt = () => {
const form = useForm({
initialValues: {
first_name: '',
last_name: ''
},
validate: {
first_name: (value) => {
if (value.length === 0) return 'First name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
},
last_name: (value) => {
if (value.length === 0) return 'Last name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
}
}
})
const { mutate: createUser, isPending } = useCreateUser();
const handleSubmit = (data: { first_name: string, last_name: string }) => {
form.reset();
return createUser(data);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
id="first_name"
label='First Name'
key={form.key('first_name')}
{...form.getInputProps('first_name')}
/>
<TextInput
id="last_name"
label='Last Name'
key={form.key('last_name')}
{...form.getInputProps('last_name')}
/>
<Button loading={isPending} type='submit' w='100%' mt='10px' variant='filled'>Create Account</Button>
</form>
)
}
export default NamePrompt;

View File

@@ -0,0 +1,39 @@
import { Button } from "@mantine/core";
import PhoneNumberInput from "@/components/phone-number-input";
import { useForm } from "@mantine/form";
import useCreateCode from "../hooks/use-create-code";
const PhonePrompt = () => {
const form = useForm({
initialValues: {
number: ''
},
validate: {
number: (value) => {
if (value.length === 0) return 'Phone number is required'
if (value.length !== 10) return 'Phone number must be 10 digits'
}
}
})
const { mutate: createCode, isPending } = useCreateCode();
const handleSubmit = (data: { number: string }) => {
form.reset();
return createCode(data.number);
}
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<PhoneNumberInput
id="number"
label='Enter your phone number'
key={form.key('number')}
{...form.getInputProps('number')}
/>
<Button type='submit' w='100%' mt='10px' variant='filled' loading={isPending}>Send Code</Button>
</form>
)
}
export default PhonePrompt;

View File

@@ -0,0 +1,21 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
p='xs'
w='45%'
h='fit-content'
onClick={onClick}
variant='outline'
>
<SimpleGrid style={{ overflow: 'hidden' }}>
<Center>
<ChalkboardTeacherIcon size='3rem' />
</Center>
<Text size='md' fw={600}>Returning Player</Text>
</SimpleGrid>
</Button>
};
export default ExistingPlayerButton;

View File

@@ -0,0 +1,130 @@
import { useState, FormEventHandler, useMemo } from 'react';
import { ArrowLeftIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { Autocomplete, Button, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
import ExistingPlayerButton from './existing-player-button';
import NewPlayerButton from './new-player-button';
import { Player } from '@/features/players/types';
import { toast } from 'sonner';
import { playerQueries } from '@/features/players/queries';
import useCreateUser from '../../hooks/use-create-user';
enum PlayerPromptStage {
returning = 'returning',
new = 'new'
}
const PlayerPrompt = () => {
const [stage, setStage] = useState<PlayerPromptStage>();
const playersQuery = useQuery(playerQueries.unassociated());
const { mutate: createUser, isPending } = useCreateUser();
const players = playersQuery.data;
const [player, setPlayer] = useState<Player>();
const [value, setValue] = useState('');
const [error, setError] = useState('');
const parsedPlayers = useMemo(() => players?.map(p => ({ label: `${p.first_name} ${p.last_name}`, value: p})), [players])
const autocompleteOptions = [...new Set(parsedPlayers?.map(p => p.label))]
const formSubmitHandler = (callback: () => void): FormEventHandler<HTMLFormElement> => {
return async (event) => {
event.preventDefault();
await callback();
}
}
const handleNewPlayerSubmit = () => {
const first_name = value.split(' ').slice(0, -1).join(' ');
const last_name = value.split(' ').slice(-1).join(' ');
// check if player already exists
if (!!parsedPlayers?.find(p => p.label === value)) {
toast.error("Player already exists");
return;
}
createUser({
first_name,
last_name
});
}
const handlePlayerSubmit = () => {
if (player) {
setError('');
createUser(player.id!);
} else {
setError('You must select a player from the dropdown. If you don\'t see yourself, please go back and select \'New Player\'');
}
}
const handleNewPlayerChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
}
const handleReturningPlayerChange = (player: string) => {
const selected = parsedPlayers?.find(p => p.label === player);
if (selected) {
setError('');
setPlayer(selected.value);
} else {
setPlayer(undefined);
}
}
if (!stage) {
return <>
<Title order={3}>Have you played before?</Title>
<Text size='xs' mb='sm'>If this is your first time participating, please select <i>New Player</i>, otherwise select <i>Returning Player</i></Text>
<Flex justify='space-around'>
<ExistingPlayerButton onClick={() => setStage(PlayerPromptStage.returning)} />
<Divider orientation='vertical' variant="dashed" />
<NewPlayerButton onClick={() => setStage(PlayerPromptStage.new)} />
</Flex>
</>
}
return <>
<UnstyledButton
onClick={() => setStage(undefined)}
style={{
position: 'absolute',
top: 24,
left: 24,
}}
>
<Flex align='center' gap='xs'>
<ArrowLeftIcon size={24} />
</Flex>
</UnstyledButton>
{
stage === PlayerPromptStage.new ?
<>
<form onSubmit={formSubmitHandler(handleNewPlayerSubmit)}>
<TextInput
label='Enter your name'
placeholder='Salah Atiyeh'
value={value}
onChange={handleNewPlayerChange}
/>
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button>
</form>
</> :
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
<Autocomplete
label='Enter your name'
placeholder='Salah Atiyeh'
data={autocompleteOptions}
onChange={handleReturningPlayerChange}
error={error}
/>
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button>
</form>
}
</>
};
export default PlayerPrompt;

View File

@@ -0,0 +1,21 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { UserPlusIcon } from "@phosphor-icons/react";
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
return <Button
p='xs'
w='45%'
h='fit-content'
onClick={onClick}
variant='outline'
>
<SimpleGrid>
<Center>
<UserPlusIcon size='3rem' />
</Center>
<Text size='md' fw={600}>New Player</Text>
</SimpleGrid>
</Button>
};
export default NewPlayerButton;

View File

@@ -0,0 +1,47 @@
import { consumeCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchMe } from "@/features/players/server";
import { useNavigate } from "@tanstack/react-router";
import { authQueryConfig } from "@/contexts/auth-context";
import toast from '@/lib/sonner'
const useConsumeCode = (onWrongCode: () => void) => {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
onSuccess: async (data) => {
if (data.status === 'OK') {
const data = await fetchMe();
queryClient.setQueryData(authQueryConfig.queryKey, data);
if (!data || !data.user) {
navigate({ to: '/login', search: { stage: 'name' } });
} else {
toast.success('Successfully logged in. Welcome back!');
navigate({ to: '/' })
}
} else if (data.status === 'INCORRECT_USER_INPUT_CODE_ERROR') {
onWrongCode();
} else if (data.status === 'EXPIRED_USER_INPUT_CODE_ERROR') {
toast.error('Code has expired. Please request a new code.');
} else if (data.status === "RESTART_FLOW_ERROR") {
toast.error('Too many failed attempts. Please try again.');
navigate({ to: '/login', search: { stage: undefined, number: undefined } });
} else {
toast.error('Unknown error. Please try again later.');
}
return data;
},
onError: (error: any) => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error("Unknown error. Please try again later.");
}
},
});
};
export default useConsumeCode;

View File

@@ -0,0 +1,29 @@
import { createCode } from "supertokens-web-js/recipe/passwordless";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import toast from '@/lib/sonner'
const useCreateCode = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: (phoneNumber: string) => createCode({ phoneNumber: '+1' + phoneNumber }),
onSuccess: (data, phoneNumber) => {
if (data.status === 'OK') {
toast.success('Code sent successfully');
navigate({ to: '/login', search: { stage: 'code', number: phoneNumber } });
} else {
toast.error(data.reason);
}
},
onError: (error: any) => {
if (error.isSuperTokensGeneralError === true) {
toast.error(error.message);
} else {
toast.error('An unexpected error occurred when trying to send a one-time passcode. Please try again later.');
}
},
});
};
export default useCreateCode;

View File

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

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
export interface Match {
id: string;
order: number;
lid: number;
reset: boolean;
round: number;
home_cups: number;
away_cups: number;
ot_count: number;
start_time: string;
end_time: string;
bye: boolean;
home_from_lid: number;
away_from_lid: number;
home_from_loser: boolean;
away_from_loser: boolean;
bracket_type: 'winners' | 'losers';
tournament_id: string;
home_id: string;
away_id: string;
created: string;
updated: string;
}
export const matchInputSchema = z.object({
order: z.number().int().min(1).optional(),
lid: z.number().int().min(1),
reset: z.boolean().optional().default(false),
round: z.number().int().min(1),
home_cups: z.number().int().min(0).optional().default(0),
away_cups: z.number().int().min(0).optional().default(0),
ot_count: z.number().int().min(0).optional().default(0),
start_time: z.iso.datetime("Invalid start time format").optional(),
end_time: z.iso.datetime("Invalid end time format").optional(),
bye: z.boolean().optional().default(false),
home_from_lid: z.number().int().min(1).optional(),
away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional().default(false),
away_from_loser: z.boolean().optional().default(false),
losers_bracket: z.boolean().optional().default(false),
tournament_id: z.string().min(1),
home_id: z.string().min(1).optional(),
away_id: z.string().min(1).optional(),
}).refine(
(data) => {
if (data.start_time && data.end_time) {
return new Date(data.start_time) < new Date(data.end_time);
}
return true;
},
{ message: "End time must be after start time", path: ["end_time"] }
);
export type MatchInput = z.infer<typeof matchInputSchema>;
export type MatchUpdateInput = Partial<MatchInput>;

View File

@@ -0,0 +1,40 @@
import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
import { Player } from "@/features/players/types";
interface PlayerListProps {
players: Player[];
loading?: boolean;
}
const PlayerList = ({ players, loading = false }: PlayerListProps) => {
const navigate = useNavigate();
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton circle height={40} width={40} />}
>
<Skeleton height={20} width={200} />
</ListItem>
))}
</List>
return <List>
{players?.map((player) => (
<ListItem key={player.id}
py='xs'
icon={<Avatar size={40} name={`${player.first_name} ${player.last_name}`} />}
style={{ cursor: 'pointer' }}
onClick={() => {
navigate({ to: `/profile/${player.id}` });
}}
>
<Text fw={500}>{`${player.first_name} ${player.last_name}`}</Text>
</ListItem>
))}
</List>
}
export default PlayerList;

View File

@@ -0,0 +1,42 @@
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { Flex, Title, ActionIcon } from "@mantine/core";
import { PencilIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import NameUpdateForm from "./name-form";
import Avatar from "@/components/avatar";
import { useSheet } from "@/hooks/use-sheet";
import { Player } from "../../types";
interface HeaderProps {
player: Player;
}
const Header = ({ player }: HeaderProps) => {
const sheet = useSheet();
const { user: authUser } = useAuth();
const owner = useMemo(() => authUser?.id === player.id, [authUser?.id, player.id]);
const name = useMemo(() => `${player.first_name} ${player.last_name}`, [player.first_name, player.last_name]);
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={125} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{name}</Title>
<ActionIcon display={owner ? 'block' : 'none'} radius='xl' variant='subtle' onClick={sheet.open}>
<PencilIcon size={20} />
</ActionIcon>
</Flex>
</Flex>
<Sheet title='Update Name' {...sheet.props}>
<NameUpdateForm player={player} toggle={sheet.toggle} />
</Sheet>
</>
)
};
export default Header;

View File

@@ -0,0 +1,22 @@
import { Box, Button, Text } from "@mantine/core";
import Header from "./header";
import { testEvent } from "@/utils/test-event";
import { Player } from "@/features/players/types";
import TeamList from "@/features/teams/components/team-list";
interface ProfileProps {
player: Player;
}
const Profile = ({ player }: ProfileProps) => {
return <>
<Header player={player} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Teams</Text>
<TeamList teams={player.teams ?? []} />
</Box>
</>;
};
export default Profile;

View File

@@ -0,0 +1,60 @@
import { updatePlayer } from "@/features/players/server";
import { useMutation } from "@tanstack/react-query";
import { Button, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form";
import toast from "@/lib/sonner";
import { useRouter } from "@tanstack/react-router";
import { Player } from "../../types";
interface NameUpdateFormProps {
player: Player;
toggle: () => void;
}
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
const router = useRouter();
const form = useForm({
initialValues: {
first_name: player.first_name,
last_name: player.last_name
},
validate: {
first_name: (value: string) => {
if (value.length === 0) return 'First name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'First name must be 3-20 characters long and contain only letters'
},
last_name: (value: string) => {
if (value.length === 0) return 'Last name is required'
if (!(/^[a-zA-Z\s]{3,20}$/).test(value)) return 'Last name must be 3-20 characters long and contain only letters'
}
}
})
const { mutate: updateName, isPending } = useMutation({
mutationFn: async (data: { first_name: string, last_name: string }) => await updatePlayer({ data }),
onSuccess: () => {
toggle();
toast.success('Name updated successfully!');
router.invalidate();
},
onError: () => {
toast.error('There was an issue updating your name. Please try again later.');
}
});
const handleSubmit = async (data: { first_name: string, last_name: string }) => await updateName(data)
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap='xs'>
<TextInput label='First Name' {...form.getInputProps('first_name')} />
<TextInput label='Last Name' {...form.getInputProps('last_name')} />
<Button fullWidth loading={isPending} type='submit'>Save</Button>
<Button fullWidth variant='subtle' color='red' onClick={toggle}>Cancel</Button>
</Stack>
</form>
)
}
export default NameUpdateForm;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Players');

View File

@@ -0,0 +1,23 @@
import { queryOptions } from "@tanstack/react-query";
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
const playerKeys = {
list: ['players', 'list'] as const,
details: (id: string) => ['players', 'details', id] as const,
unassociated: ['players','unassociated'] as const,
};
export const playerQueries = {
list: () => queryOptions({
queryKey: playerKeys.list,
queryFn: listPlayers,
}),
details: (id: string) => queryOptions({
queryKey: playerKeys.details(id),
queryFn: () => getPlayer({ data: id }),
}),
unassociated: () => queryOptions({
queryKey: playerKeys.unassociated,
queryFn: getUnassociatedPlayers,
}),
};

View File

@@ -0,0 +1,143 @@
import { fetchSuperTokensAuth, setUserMetadata, superTokensFunctionMiddleware, superTokensRoleFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { logger } from ".";
export const fetchMe = createServerFn().handler(async () => {
const data = await fetchSuperTokensAuth();
if (!data || !data.userAuthId) return { user: undefined, roles: [], metadata: {} };
try {
const result = await pbAdmin.getPlayerByAuthId(data.userAuthId);
logger.info('Fetched player', result);
return {
user: result || undefined,
roles: data.roles,
metadata: data.metadata
};
} catch (error) {
logger.error('Error fetching player:', error);
return { user: undefined, roles: data.roles, metadata: data.metadata };
}
});
export const getPlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => {
try {
const player = await pbAdmin.getPlayer(data);
return player;
} catch (error) {
logger.error('Error getting player', error);
return undefined;
}
});
export const updatePlayer = createServerFn()
.validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
// Find the player by authId first
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!existing) return;
// Update the player
const updatedPlayer = await pbAdmin.updatePlayer(
existing.id!,
{
first_name: data.first_name,
last_name: data.last_name
}
);
logger.info('Updated player name', updatedPlayer);
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
return updatedPlayer;
} catch (error) {
logger.error('Error updating player name', error);
return undefined;
}
});
export const createPlayer = createServerFn()
.validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (existing) return;
logger.info('Creating player', data, userAuthId);
const newPlayer = await pbAdmin.createPlayer({
auth_id: userAuthId,
first_name: data.first_name,
last_name: data.last_name
});
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
logger.info('Created player', newPlayer);
return newPlayer;
} catch (error) {
logger.error('Error creating player', error);
return undefined;
}
});
export const associatePlayer = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => {
const userAuthId = (context as any).userAuthId;
if (!userAuthId) return;
try {
await pbAdmin.updatePlayer(data, {
auth_id: userAuthId
});
await setUserMetadata({ data: { player_id: data } });
const player = await pbAdmin.getPlayer(data);
logger.info('Associated player', player);
return player;
} catch (error) {
logger.error('Error associating player', error);
return undefined;
}
});
export const listPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.listPlayers();
} catch (error) {
logger.error('Error listing players', error);
return [];
}
});
export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
return await pbAdmin.getUnassociatedPlayers();
} catch (error) {
logger.error('Error getting unassociated players', error);
return [];
}
});

View File

@@ -0,0 +1,23 @@
import { Team } from "@/features/teams/types";
import { z } from 'zod';
export interface Player {
id?: string;
auth_id?: string;
first_name?: string;
last_name?: string;
created?: string;
updated?: string;
teams?: Team[];
}
export const playerInputSchema = z.object({
auth_id: z.string().optional(),
first_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 3-20 characters long and contain only letters and spaces"),
last_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-20 characters long and contain only letters and spaces"),
});
export const playerUpdateSchema = playerInputSchema.partial();
export type PlayerInput = z.infer<typeof playerInputSchema>;
export type PlayerUpdateInput = z.infer<typeof playerUpdateSchema>;

View File

@@ -0,0 +1,61 @@
import { Box, ColorSwatch, Group, Text } from '@mantine/core';
import { updateUserAccentColor } from '@/utils/supertokens';
import { useAuth } from '@/contexts/auth-context';
const colors = ['blue', 'red', 'green', 'yellow', 'grape', 'orange', 'pink', 'lime'];
interface ColorButtonProps {
color: string;
handleClick: (color: string) => void;
isSelected: boolean;
}
const ColorButton = ({ color, handleClick, isSelected }: ColorButtonProps) => {
return (
<Box
m={isSelected ? 0 :'0.125rem'}
bd={isSelected ? '0.125rem solid var(--mantine-color-bright)' : 'none'}
style={{ borderRadius: '50%' }}
>
<ColorSwatch
component='button'
color={`var(--mantine-color-${color}-6)`}
onClick={() => handleClick(color)}
size={28}
m={2}
style={{
color: '#fff',
cursor: 'pointer',
}}
/>
</Box>
);
};
const AccentColorPicker = () => {
const { metadata, user, set } = useAuth()
const handleClick = async (color: string) => {
if (user) {
await updateUserAccentColor({ data: color });
set({ metadata: { ...metadata, accentColor: color } })
}
}
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Accent Color</Text>
<Group gap='xs' w='100%' justify='space-between'>
{colors.map((color) => (
<ColorButton
key={color}
color={color}
handleClick={handleClick}
isSelected={metadata.accentColor === color}
/>
))}
</Group>
</Box>
);
}
export default AccentColorPicker;

View File

@@ -0,0 +1,51 @@
import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core';
import { SunIcon, MoonIcon, Icon, MonitorIcon } from '@phosphor-icons/react'
import { updateUserColorScheme } from '@/utils/supertokens';
import { useAuth } from '@/contexts/auth-context';
interface ColorSchemeLabelProps {
colorScheme: string;
Icon: Icon;
}
const ColorSchemeLabel: React.FC<ColorSchemeLabelProps> = ({ colorScheme, Icon }) => {
return (<Center style={{ gap: 10 }}>
<Icon size={16} />
<span>{colorScheme}</span>
</Center>)
}
export function ColorSchemePicker() {
const { metadata, user, set } = useAuth()
const handleClick = async (value: string) => {
if (user) {
await updateUserColorScheme({ data: value });
set({ metadata: { ...metadata, colorScheme: value as MantineColorScheme } })
}
}
return (
<Box>
<Text fw={500} size='sm' mb='xs'>Color Scheme</Text>
<SegmentedControl
w='100%'
value={metadata.colorScheme}
onChange={handleClick}
data={[
{
value: 'dark',
label: <ColorSchemeLabel colorScheme='Dark' Icon={MoonIcon} />
},
{
value: 'light',
label: <ColorSchemeLabel colorScheme='Light' Icon={SunIcon} />
},
{
value: 'auto',
label: <ColorSchemeLabel colorScheme='System' Icon={MonitorIcon} />
},
]}
/>
</Box>
)
}

View File

@@ -0,0 +1,42 @@
import { Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router";
interface TeamListProps {
teams: Team[];
loading?: boolean;
}
const TeamList = ({ teams, loading = false }: TeamListProps) => {
const navigate = useNavigate();
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton height={40} width={40} />}
>
<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={() => navigate({ to: `/teams/${team.id}` })}
>
<Stack gap={0}>
<Text fw={500}>{`${team.name}`}</Text>
{team.players?.map(p => <Text size='xs' c='dimmed'>{p.first_name} {p.last_name}</Text>)}
</Stack>
</ListItem>
))}
</List>
}
export default TeamList;

View File

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

View File

@@ -0,0 +1,21 @@
import { Box, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import { Team } from "../../types";
import PlayerList from "@/features/players/components/player-list";
interface ProfileProps {
team: Team;
}
const TeamProfile = ({ team }: ProfileProps) => {
return <>
<Header team={team} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Players</Text>
<PlayerList players={team.players} />
</Box>
</>;
};
export default TeamProfile;

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger("Teams");

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import { Player } from "@/features/players/types";
import { z } from 'zod';
export interface Team {
id: string;
name: string;
logo_url: string;
primary_color: string;
accent_color: string;
song_id: string;
song_name: string;
song_artist: string;
song_album: string;
song_year: number;
song_start: number;
song_end: number;
song_image_url: string;
created: string;
updated: string;
players: Player[];
}
export const teamInputSchema = z.object({
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
logo_url: z.url("Invalid logo URL").optional(),
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
song_id: z.string().max(255).optional(),
song_name: z.string().max(255).optional(),
song_artist: z.string().max(255).optional(),
song_album: z.string().max(255).optional(),
song_year: z.number().int().optional(),
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
}).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 TeamUpdateInput = Partial<TeamInput>;

View File

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

View File

@@ -0,0 +1,3 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Tournaments');

View File

@@ -0,0 +1,18 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
import { getTournament, listTournaments } from "./server";
const tournamentKeys = {
list: ['tournaments'] as const,
details: (id: string) => [...tournamentKeys.list, id] as const,
};
export const tournamentQueries = {
list: () => queryOptions({
queryKey: tournamentKeys.list,
queryFn: listTournaments,
}),
details: (id: string) => queryOptions({
queryKey: tournamentKeys.details(id),
queryFn: () => getTournament({ data: id }),
}),
};

View File

@@ -0,0 +1,43 @@
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client";
import { tournamentInputSchema } from "@/features/tournaments/types";
import { logger } from ".";
import { z } from "zod";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () => {
try {
const result = await pbAdmin.listTournaments();
return result;
} catch (error) {
logger.error('Error fetching tournaments', error);
return [];
}
});
export const createTournament = createServerFn()
.validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Creating tournament', data);
const tournament = await pbAdmin.createTournament(data);
return tournament;
} catch (error) {
logger.error('Error creating tournament', error);
return null;
}
});
export const getTournament = createServerFn()
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => {
logger.info('Getting tournament', tournamentId);
const tournament = await pbAdmin.getTournament(tournamentId);
return tournament;
});

View File

@@ -0,0 +1,45 @@
import { Team } from "@/features/teams/types";
import { z } from "zod";
export interface Tournament {
id: string;
name: string;
location?: string;
desc?: string;
rules?: string;
logo_url?: string;
enroll_time?: string;
start_time: string;
end_time?: string;
created: string;
updated: string;
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_url: z.string().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
// Schema for the server input (with base64 logo)
export const tournamentInputSchema = z.object({
name: z.string(),
location: z.string().optional(),
desc: z.string().optional(),
rules: z.string().optional(),
logo_url: z.string().optional(),
enroll_time: z.string(),
start_time: z.string(),
end_time: z.string().optional(),
});
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
export type TournamentUpdateInput = Partial<TournamentInput>;

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