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

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 />
})