Compare commits

5 Commits

Author SHA1 Message Date
yohlo
2b8ccf1649 various improvements, edit tournament, etc 2025-08-24 22:56:48 -05:00
yohlo
936ab0ce72 tournament logo upload via api 2025-08-24 13:50:48 -05:00
yohlo
466a3365f0 random imporvements 2025-08-24 12:53:34 -05:00
yohlo
1015f63f7e swipeable tabs using mantine carousel 2025-08-24 10:41:31 -05:00
yohlo
fb1e4c3ee7 rename default header title 2025-08-24 00:37:17 -05:00
39 changed files with 1205 additions and 105 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ yarn.lock
/playwright/.cache/ /playwright/.cache/
/scripts/ /scripts/
/pb_data/ /pb_data/
/.tanstack/

View File

@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4", "@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4", "@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4", "@mantine/dates": "^8.2.4",
@@ -28,6 +29,7 @@
"@types/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"drizzle-orm": "^0.44.4", "drizzle-orm": "^0.44.4",
"drizzle-zod": "^0.8.3", "drizzle-zod": "^0.8.3",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0", "ioredis": "^5.7.0",
"pg": "^8.16.3", "pg": "^8.16.3",

View File

@@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("text156371623")
// add field
collection.fields.addAt(9, new Field({
"hidden": false,
"id": "file3834550803",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(9, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text156371623",
"max": 0,
"min": 0,
"name": "logo_url",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// remove field
collection.fields.removeById("file3834550803")
return app.save(collection)
})

View File

@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// remove field
collection.fields.removeById("url156371623")
// add field
collection.fields.addAt(14, new Field({
"hidden": false,
"id": "file3834550803",
"maxSelect": 1,
"maxSize": 0,
"mimeTypes": [],
"name": "logo",
"presentable": false,
"protected": false,
"required": false,
"system": false,
"thumbs": [],
"type": "file"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1568971955")
// add field
collection.fields.addAt(2, new Field({
"exceptDomains": null,
"hidden": false,
"id": "url156371623",
"name": "logo_url",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": false,
"type": "url"
}))
// remove field
collection.fields.removeById("file3834550803")
return app.save(collection)
})

View File

@@ -23,9 +23,12 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test' import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$' import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
const rootServerRouteImport = createServerRootRoute() const rootServerRouteImport = createServerRootRoute()
@@ -89,11 +92,23 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
path: '/preview', path: '/preview',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsIdRoute =
AuthedAdminTournamentsIdRouteImport.update({
id: '/tournaments/$id',
path: '/tournaments/$id',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTestServerRoute = ApiTestServerRouteImport.update({ const ApiTestServerRoute = ApiTestServerRouteImport.update({
id: '/api/test', id: '/api/test',
path: '/api/test', path: '/api/test',
getParentRoute: () => rootServerRouteImport, getParentRoute: () => rootServerRouteImport,
} as any) } as any)
const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({ const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
id: '/api/events/$', id: '/api/events/$',
path: '/api/events/$', path: '/api/events/$',
@@ -104,6 +119,12 @@ const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
path: '/api/auth/$', path: '/api/auth/$',
getParentRoute: () => rootServerRouteImport, getParentRoute: () => rootServerRouteImport,
} as any) } as any)
const ApiFilesCollectionRecordIdFileServerRoute =
ApiFilesCollectionRecordIdFileServerRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootServerRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -117,6 +138,7 @@ export interface FileRoutesByFullPath {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -129,6 +151,7 @@ export interface FileRoutesByTo {
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -144,6 +167,7 @@ export interface FileRoutesById {
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -159,6 +183,7 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -171,6 +196,7 @@ export interface FileRouteTypes {
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id'
id: id:
| '__root__' | '__root__'
| '/_authed' | '/_authed'
@@ -185,6 +211,7 @@ export interface FileRouteTypes {
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -196,30 +223,54 @@ export interface FileServerRoutesByFullPath {
'/api/test': typeof ApiTestServerRoute '/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
export interface FileServerRoutesByTo { export interface FileServerRoutesByTo {
'/api/test': typeof ApiTestServerRoute '/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
export interface FileServerRoutesById { export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport __root__: typeof rootServerRouteImport
'/api/test': typeof ApiTestServerRoute '/api/test': typeof ApiTestServerRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute '/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute '/api/events/$': typeof ApiEventsSplatServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
} }
export interface FileServerRouteTypes { export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths: '/api/test' | '/api/auth/$' | '/api/events/$' fullPaths:
| '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesByTo: FileServerRoutesByTo fileServerRoutesByTo: FileServerRoutesByTo
to: '/api/test' | '/api/auth/$' | '/api/events/$' to:
id: '__root__' | '/api/test' | '/api/auth/$' | '/api/events/$' | '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
id:
| '__root__'
| '/api/test'
| '/api/auth/$'
| '/api/events/$'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesById: FileServerRoutesById fileServerRoutesById: FileServerRoutesById
} }
export interface RootServerRouteChildren { export interface RootServerRouteChildren {
ApiTestServerRoute: typeof ApiTestServerRoute ApiTestServerRoute: typeof ApiTestServerRoute
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -308,6 +359,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminPreviewRouteImport preLoaderRoute: typeof AuthedAdminPreviewRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/$id': {
id: '/_authed/admin/tournaments/$id'
path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id'
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
parentRoute: typeof AuthedAdminRoute
}
} }
} }
declare module '@tanstack/react-start/server' { declare module '@tanstack/react-start/server' {
@@ -319,6 +377,13 @@ declare module '@tanstack/react-start/server' {
preLoaderRoute: typeof ApiTestServerRouteImport preLoaderRoute: typeof ApiTestServerRouteImport
parentRoute: typeof rootServerRouteImport parentRoute: typeof rootServerRouteImport
} }
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/events/$': { '/api/events/$': {
id: '/api/events/$' id: '/api/events/$'
path: '/api/events/$' path: '/api/events/$'
@@ -333,17 +398,26 @@ declare module '@tanstack/react-start/server' {
preLoaderRoute: typeof ApiAuthSplatServerRouteImport preLoaderRoute: typeof ApiAuthSplatServerRouteImport
parentRoute: typeof rootServerRouteImport parentRoute: typeof rootServerRouteImport
} }
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileServerRouteImport
parentRoute: typeof rootServerRouteImport
}
} }
} }
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
} }
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
} }
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
@@ -385,6 +459,9 @@ const rootServerRouteChildren: RootServerRouteChildren = {
ApiTestServerRoute: ApiTestServerRoute, ApiTestServerRoute: ApiTestServerRoute,
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute,
} }
export const serverRouteTree = rootServerRouteImport export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren) ._addFileChildren(rootServerRouteChildren)

View File

@@ -1,5 +1,6 @@
import '@mantine/core/styles.css'; import '@mantine/core/styles.css';
import '@mantine/dates/styles.css'; import '@mantine/dates/styles.css';
import '@mantine/carousel/styles.css';
import { import {
HeadContent, HeadContent,
Navigate, Navigate,
@@ -12,7 +13,7 @@ import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
import { type QueryClient } from '@tanstack/react-query' import { type QueryClient } from '@tanstack/react-query'
import { ensureSuperTokensFrontend } from '@/lib/supertokens/client' import { ensureSuperTokensFrontend } from '@/lib/supertokens/client'
import { AuthContextType, authQueryConfig } from '@/contexts/auth-context' import { AuthContextType, authQueryConfig } from '@/contexts/auth-context'
import Providers from '@/components/providers' import Providers from '@/features/core/components/providers'
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core'; import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
import { HeaderConfig } from '@/features/core/types/header-config'; import { HeaderConfig } from '@/features/core/types/header-config';

View File

@@ -0,0 +1,33 @@
import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/contexts/auth-context'
import EditTournament from '@/features/admin/components/edit-tournament'
import Page from '@/components/page'
import { Loader } from '@mantine/core'
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.details(params.id))
},
loader: () => ({
header: {
withBackButton: true,
title: 'Edit Tournament',
},
}),
component: RouteComponent,
})
function RouteComponent() {
const { id } = Route.useParams()
const { data: tournament } = useQuery(tournamentQueries.details(id))
if (!tournament) throw new Error("Tournament not found.")
return (
<Page>
<EditTournament tournament={tournament} />
</Page>
)
}

View File

@@ -2,11 +2,14 @@ import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'; import { tournamentQueries } from '@/features/tournaments/queries';
import Page from '@/components/page' import Page from '@/components/page'
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Box, Button } from '@mantine/core'; import { Box, Group, Title } from '@mantine/core';
import { useSheet } from '@/hooks/use-sheet'; import { useSheet } from '@/hooks/use-sheet';
import Sheet from '@/components/sheet/sheet'; import Sheet from '@/components/sheet/sheet';
import { Tournament } from '@/features/tournaments/types'; import { Tournament } from '@/features/tournaments/types';
import TeamList from '@/features/teams/components/team-list'; import TeamList from '@/features/teams/components/team-list';
import Button from '@/components/button';
import Avatar from '@/components/avatar';
import Profile from '@/features/tournaments/components/profile';
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -28,28 +31,8 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
function RouteComponent() { function RouteComponent() {
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId)); const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
const sheet = useSheet()
return <Page noPadding> return <Page noPadding>
<Box mt='xl' p='md'> <Profile tournament={tournament!} />
<h3 style={{ marginTop: 0 }}>
{tournament?.name}
</h3>
<Button onClick={() => sheet.open()}>
View Teams
</Button>
</Box>
<Sheet
{...sheet.props}
title='Teams'
>
<TeamDrawer tournament={tournament!} />
</Sheet>
</Page> </Page>
} }

View File

@@ -1,5 +1,5 @@
import Page from '@/components/page' import Page from '@/components/page'
import { Button, Stack } from '@mantine/core' import { Stack } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { TournamentCard } from '@/features/tournaments/components/tournament-card' import { TournamentCard } from '@/features/tournaments/components/tournament-card'
import { tournamentQueries } from '@/features/tournaments/queries' import { tournamentQueries } from '@/features/tournaments/queries'
@@ -9,6 +9,7 @@ import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/sheet' import Sheet from '@/components/sheet/sheet'
import CreateTournament from '@/features/admin/components/create-tournament' import CreateTournament from '@/features/admin/components/create-tournament'
import { PlusIcon } from '@phosphor-icons/react' import { PlusIcon } from '@phosphor-icons/react'
import Button from '@/components/button'
export const Route = createFileRoute('/_authed/tournaments/')({ export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => { beforeLoad: async ({ context }) => {

View File

@@ -0,0 +1,95 @@
import { createServerFileRoute } from "@tanstack/react-start/server";
import { logger } from "@/lib/logger";
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
GET: async ({ params, request }) => {
try {
const { collection, recordId, file } = params;
const pocketbaseUrl = process.env.VITE_POCKETBASE_URL || 'http://127.0.0.1:8090';
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
logger.info('File proxy', {
collection,
recordId,
file,
targetUrl: fileUrl
});
const response = await fetch(fileUrl, {
method: 'GET',
headers: {
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }),
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }),
},
});
if (!response.ok) {
logger.error('PocketBase file request failed', {
status: response.status,
statusText: response.statusText,
url: fileUrl
});
if (response.status === 404) {
return new Response('File not found', { status: 404 });
}
return new Response(`PocketBase error: ${response.statusText}`, {
status: response.status
});
}
const body = response.body;
const responseHeaders = new Headers();
const headers = [
'content-type',
'content-length',
'content-disposition',
'etag',
'last-modified',
'cache-control',
'accept-ranges',
'content-range'
];
headers.forEach(header => {
const value = response.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
});
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
logger.info('File proxy response', {
status: response.status,
contentType: response.headers.get('content-type'),
contentLength: response.headers.get('content-length')
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
} catch (error) {
logger.error('File proxy error', error);
return new Response('Internal server error', { status: 500 });
}
},
OPTIONS: () => {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Max-Age': '86400',
}
});
}
});

View File

@@ -0,0 +1,115 @@
import { createServerFileRoute } from '@tanstack/react-start/server';
import { superTokensRequestMiddleware } from '@/utils/supertokens';
import { pbAdmin } from '@/lib/pocketbase/client';
import { logger } from '@/lib/logger';
import { z } from 'zod';
const uploadSchema = z.object({
tournamentId: z.string().min(1, 'Tournament ID is required'),
});
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo')
.middleware([superTokensRequestMiddleware])
.methods({
POST: async ({ request, context }) => {
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 });
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
const formData = await request.formData();
const tournamentId = formData.get('tournamentId') as string;
const logoFile = formData.get('logo') as File;
const validationResult = uploadSchema.safeParse({ tournamentId });
if (!validationResult.success) {
return new Response(JSON.stringify({
error: 'Invalid input',
details: validationResult.error.issues
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
if (!logoFile || logoFile.size === 0) {
return new Response(JSON.stringify({
error: 'Logo file is required'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(logoFile.type)) {
return new Response(JSON.stringify({
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) {
return new Response(JSON.stringify({
error: 'File too large. Maximum size is 10MB.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const tournament = await pbAdmin.getTournament(tournamentId);
if (!tournament) {
return new Response(JSON.stringify({
error: 'Tournament not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
logger.info('Uploading tournament logo', {
tournamentId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId
});
const pbFormData = new FormData();
pbFormData.append('logo', logoFile);
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
logger.info('Tournament logo uploaded successfully', {
tournamentId,
logo: updatedTournament.logo
});
return new Response(JSON.stringify({
success: true,
tournament: updatedTournament,
message: 'Logo uploaded successfully'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
logger.error('Error uploading tournament logo:', error);
return new Response(JSON.stringify({
error: 'Failed to upload logo',
message: error.message || 'Unknown error occurred'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
});

View File

@@ -1,53 +1,161 @@
import { import {
ErrorComponent,
Link, Link,
rootRouteId, rootRouteId,
useMatch, useMatch,
useRouter, useRouter,
useNavigate,
redirect,
} from '@tanstack/react-router' } from '@tanstack/react-router'
import type { ErrorComponentProps } from '@tanstack/react-router' import type { ErrorComponentProps } from '@tanstack/react-router'
import {
Box,
Button as MantineButton,
Text,
Title,
Stack,
Group,
Alert,
Collapse,
Code,
ThemeIcon
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react'
import toast from '@/lib/sonner'
import { logger } from '@/lib/logger'
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react'
import Button from './button'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) { export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
const router = useRouter() const router = useRouter()
const navigate = useNavigate()
const isRoot = useMatch({ const isRoot = useMatch({
strict: false, strict: false,
select: (state) => state.id === rootRouteId, select: (state) => state.id === rootRouteId,
}) })
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
console.error('DefaultCatchBoundary Error:', error) const errorMessage = error?.message || 'Unknown error'
const errorStack = error?.stack || 'No stack trace available'
useEffect(() => {
logger.error('DefaultCatchBoundary | ', error)
if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error('You\'ve been logged out')
router.history.push('/login')
throw redirect({ to: '/login' })
}
}, [error, errorMessage, navigate])
if (errorMessage.toLowerCase().includes('unauthorized')) {
return ( return (
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6"> <Box
<ErrorComponent error={error} /> style={{
<div className="flex gap-2 items-center flex-wrap"> display: 'flex',
<button flexDirection: 'column',
onClick={() => { alignItems: 'center',
router.invalidate() justifyContent: 'center',
minHeight: '50vh',
padding: 'var(--mantine-spacing-xl)',
}} }}
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
> >
Try Again <Stack align="center" gap="lg">
</button> <ThemeIcon color="red" size={80} radius="xl">
{isRoot ? ( <XCircleIcon size={48} />
<Link </ThemeIcon>
to="/" <Title order={2} ta="center">Access Denied</Title>
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`} <Text size="lg" c="dimmed" ta="center">
> You don't have permission to access this.
Home </Text>
</Link> <Group>
) : ( <Button
<Link variant="light"
to="/" onClick={() => window.history.back()}
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 Go Back
</Link> </Button>
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
</Group>
</Stack>
</Box>
)
}
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
padding: 'var(--mantine-spacing-xl)',
}}
>
<Stack align="center" gap="lg" maw={600}>
<ThemeIcon color="red" size={80} radius="xl">
<ExclamationMarkIcon size={48} />
</ThemeIcon>
<Title order={2} ta="center">Something went wrong</Title>
<Text size="lg" c="dimmed" ta="center">
There was an unexpected error. Please try again later.
</Text>
<Alert
variant="light"
color="red"
title="Error Details"
w="100%"
>
<Text mb="sm">{errorMessage}</Text>
<Button
variant="subtle"
size="compact-sm"
onClick={toggleDetails}
>
{detailsOpened ? 'Hide' : 'Show'} technical details
</Button>
<Collapse in={detailsOpened}>
<Code block mt="md" p="md">
{errorStack}
</Code>
</Collapse>
</Alert>
<Group>
<Button
variant="light"
onClick={() => router.invalidate()}
>
Try Again
</Button>
{isRoot ? (
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
) : (
<Button
variant="filled"
onClick={() => window.history.back()}
>
Go Back
</Button>
)} )}
</div> </Group>
</div> </Stack>
</Box>
) )
} }

View File

@@ -4,11 +4,14 @@ interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'siz
name: string; name: string;
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean;
} }
const Avatar = ({ name, size = 35, radius = '100%', ...props }: AvatarProps) => { const Avatar = ({ name, size = 35, radius = '100%', withBorder = true, ...props }: AvatarProps) => {
return <Paper p={size / 20} radius={radius} withBorder> return <Paper p={size / 20} radius={radius} withBorder={withBorder}>
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} {...props} /> <MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} w='fit-content' styles={{ image: {
objectFit: 'contain'
} }} {...props} />
</Paper> </Paper>
} }

11
src/components/button.tsx Normal file
View File

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

View File

@@ -0,0 +1,78 @@
import { TextInput, Flex, Box, Button } from "@mantine/core";
import { CalendarIcon } from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import { DateTimePicker } from "../features/admin/components/date-time-picker";
interface DateInputProps {
label: string;
value: string;
onChange: (value: string) => void;
withAsterisk?: boolean;
error?: React.ReactNode;
placeholder?: string;
}
// Date input that opens a sheet with mantine date time picker when clicked
export const DateInputSheet = ({
label,
value,
onChange,
withAsterisk,
error,
placeholder
}: DateInputProps) => {
const sheet = useSheet();
const formatDisplayValue = (dateString: string) => {
if (!dateString) return '';
const date = new Date(dateString);
if (isNaN(date.getTime())) return '';
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
});
};
const handleDateChange = (newValue: string | null) => {
onChange(newValue || '');
sheet.close();
};
const dateValue = value ? new Date(value) : null;
return (
<>
<TextInput
label={label}
value={formatDisplayValue(value)}
onClick={sheet.open}
readOnly
withAsterisk={withAsterisk}
error={error}
placeholder={placeholder || "Click to select date"}
rightSection={<CalendarIcon size={16} />}
style={{ cursor: 'pointer' }}
/>
<Sheet {...sheet.props} title={`Select ${label}`}>
<Flex gap='xs' direction='column' justify='center' p="md">
<Box mx='auto'>
<DateTimePicker
value={dateValue}
onChange={handleDateChange}
/>
</Box>
<Button onClick={sheet.close} variant="subtle">
Close
</Button>
</Flex>
</Sheet>
</>
);
};

View File

@@ -1,7 +1,8 @@
import { Box, Text, Group, ActionIcon, Button, ScrollArea, Divider } from "@mantine/core"; import { Box, Text, Group, ActionIcon, ScrollArea, Divider } from "@mantine/core";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react"; import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { useState, ReactNode} from "react"; import { useState, ReactNode} from "react";
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context"; import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
import Button from "@/components/button";
interface SlidePanelProps { interface SlidePanelProps {
children: ReactNode; children: ReactNode;

View File

@@ -0,0 +1,127 @@
import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode } from "react";
interface TabItem {
label: string;
content: ReactNode;
}
interface SwipeableTabsProps {
tabs: TabItem[];
defaultTab?: number;
onTabChange?: (index: number, tab: TabItem) => void;
}
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
const [embla, setEmbla] = useState<any>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
useEffect(() => {
if (!embla) return;
const onSelect = () => {
const newIndex = embla.selectedScrollSnap();
setActiveTab(newIndex);
};
embla.on("select", onSelect);
return () => {
embla.off("select", onSelect);
};
}, [embla]);
const handleTabChange = (index: number) => {
if (index !== activeTab && index >= 0 && index < tabs.length) {
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
}
};
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
controlsRefs[index] = node;
setControlsRefs(controlsRefs);
};
return (
<Box>
<Box
ref={setRootRef}
pos="relative"
style={{
display: 'flex',
marginBottom: 'var(--mantine-spacing-md)',
}}
>
<FloatingIndicator
target={controlsRefs[activeTab]}
parent={rootRef}
styles={{
root: {
borderBottom: '2px solid var(--mantine-primary-color-filled)',
paddingInline: '0.5rem'
}
}}
/>
{tabs.map((tab, index) => (
<UnstyledButton
key={`${tab.label}-${index}`}
onClick={() => handleTabChange(index)}
style={{
flex: 1,
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
textAlign: 'center',
color: activeTab === index
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-text)',
fontWeight: activeTab === index ? 600 : 400,
transition: 'color 200ms ease, font-weight 200ms ease',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0,
}}
>
<Text
size="sm"
component="span"
style={{
display: 'inline-block',
paddingInline: '1rem',
paddingBottom: '0.25rem'
}}
ref={setControlRef(index)}
>
{tab.label}
</Text>
</UnstyledButton>
))}
</Box>
<Carousel
getEmblaApi={setEmbla}
withControls={false}
withIndicators={false}
slideSize="100%"
initialSlide={activeTab}
style={{
overflow: 'hidden'
}}
>
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box>
{tab.content}
</Box>
</Carousel.Slide>
))}
</Carousel>
</Box>
);
}
export default SwipeableTabs;

View File

@@ -1,4 +1,4 @@
import { Stack, TextInput, Textarea } from "@mantine/core"; import { FileInput, Stack, TextInput, Textarea } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form"; import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react"; import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel"; import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
@@ -6,6 +6,10 @@ import { TournamentFormInput } from "@/features/tournaments/types";
import { DateTimePicker } from "./date-time-picker"; import { DateTimePicker } from "./date-time-picker";
import { isNotEmpty } from "@mantine/form"; import { isNotEmpty } from "@mantine/form";
import useCreateTournament from "../hooks/use-create-tournament"; import useCreateTournament from "../hooks/use-create-tournament";
import toast from '@/lib/sonner';
import { logger } from "..";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
const CreateTournament = ({ close }: { close: () => void }) => { const CreateTournament = ({ close }: { close: () => void }) => {
@@ -14,7 +18,6 @@ const CreateTournament = ({ close }: { close: () => void }) => {
name: 'Test Tournament', name: 'Test Tournament',
location: 'Test Location', location: 'Test Location',
desc: 'Test Description', 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', start_time: '2025-01-01T00:00:00Z',
enroll_time: '2025-01-01T00:00:00Z', enroll_time: '2025-01-01T00:00:00Z',
}, },
@@ -28,12 +31,45 @@ const CreateTournament = ({ close }: { close: () => void }) => {
} }
const form = useForm(config); const form = useForm(config);
const queryClient = useQueryClient();
const { mutate: createTournament, isPending } = useCreateTournament(); const { mutate: createTournament, isPending } = useCreateTournament();
const handleSubmit = async (values: TournamentFormInput) => { const handleSubmit = async (values: TournamentFormInput) => {
createTournament(values, { const { logo, ...tournamentData } = values;
onSuccess: () => {
createTournament(tournamentData, {
onSuccess: async (tournament) => {
if (logo && tournament) {
try {
const formData = new FormData();
formData.append('tournamentId', tournament.id);
formData.append('logo', logo);
const response = await fetch('/api/tournaments/upload-logo', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
}
const result = await response.json();
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
queryClient.setQueryData(
tournamentQueries.details(result.tournament!.id).queryKey,
result.tournament
);
toast.success('Tournament created successfully!');
} catch (error: any) {
toast.error(`Tournament created but logo upload failed: ${error.message}`);
logger.error('Tournament logo upload error', error);
}
}
close(); close();
} }
}); });
@@ -67,12 +103,12 @@ const CreateTournament = ({ close }: { close: () => void }) => {
{...form.getInputProps('desc')} {...form.getInputProps('desc')}
/> />
<TextInput <FileInput
key={form.key('logo_url')} key={form.key('logo')}
accept="image/*" accept="image/png,image/jpeg,image/gif,image/jpg"
label="Logo" label="Logo"
leftSection={<LinkIcon size={16} />} leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo_url')} {...form.getInputProps('logo')}
/> />
<SlidePanelField <SlidePanelField

View File

@@ -0,0 +1,193 @@
import { Box, FileInput, Group, Stack, TextInput, Textarea, Title } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react";
import { Tournament, TournamentFormInput } from "@/features/tournaments/types";
import { DateInputSheet } from "../../../components/date-input";
import { isNotEmpty } from "@mantine/form";
import toast from '@/lib/sonner';
import { logger } from "..";
import { useQueryClient, useMutation } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
import { updateTournament } from "@/features/tournaments/server";
import { useRouter } from "@tanstack/react-router";
import Button from "@/components/button";
interface EditTournamentProps {
tournament: Tournament;
}
const EditTournament = ({ tournament }: EditTournamentProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const config: UseFormInput<TournamentFormInput> = {
initialValues: {
name: tournament.name || '',
location: tournament.location || '',
desc: tournament.desc || '',
start_time: tournament.start_time || '',
enroll_time: tournament.enroll_time || '',
end_time: tournament.end_time || '',
rules: tournament.rules || '',
logo: undefined, // Always start with no file selected
},
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: editTournament, isPending } = useMutation({
mutationFn: (data: Partial<TournamentFormInput>) =>
updateTournament({ data: { id: tournament.id, updates: data } }),
onSuccess: (updatedTournament) => {
if (updatedTournament) {
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
queryClient.setQueryData(
tournamentQueries.details(updatedTournament.id).queryKey,
updatedTournament
);
}
}
});
const handleSubmit = async (values: TournamentFormInput) => {
const { logo, ...tournamentData } = values;
editTournament(tournamentData, {
onSuccess: async (updatedTournament) => {
if (logo && updatedTournament) {
try {
const formData = new FormData();
formData.append('tournamentId', updatedTournament.id);
formData.append('logo', logo);
const response = await fetch('/api/tournaments/upload-logo', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to upload logo');
}
const result = await response.json();
queryClient.setQueryData(
tournamentQueries.details(result.tournament!.id).queryKey,
result.tournament
);
toast.success('Tournament updated successfully!');
} catch (error: any) {
toast.error(`Tournament updated but logo upload failed: ${error.message}`);
logger.error('Tournament logo upload error', error);
}
} else {
toast.success('Tournament updated successfully!');
}
router.history.back();
},
onError: (error: any) => {
toast.error(`Failed to update tournament: ${error.message}`);
logger.error('Tournament update error', error);
}
});
};
return (
<Box w="100%" maw={600} mx="auto" p="lg">
<Stack gap="lg">
<Title order={2}>Edit Tournament</Title>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<TextInput
label="Name"
withAsterisk
key={form.key('name')}
{...form.getInputProps('name')}
/>
<TextInput
label="Location"
withAsterisk
key={form.key('location')}
{...form.getInputProps('location')}
/>
<Textarea
label="Description"
key={form.key('desc')}
{...form.getInputProps('desc')}
minRows={3}
/>
<Textarea
label="Rules"
key={form.key('rules')}
{...form.getInputProps('rules')}
minRows={4}
/>
<FileInput
key={form.key('logo')}
accept="image/png,image/jpeg,image/gif,image/jpg"
label="Change Logo"
leftSection={<LinkIcon size={16} />}
{...form.getInputProps('logo')}
/>
<DateInputSheet
label="Start Date"
withAsterisk
value={form.values.start_time}
onChange={(value) => form.setFieldValue('start_time', value)}
error={form.errors.start_time}
/>
<DateInputSheet
label="Enrollment Due"
withAsterisk
value={form.values.enroll_time}
onChange={(value) => form.setFieldValue('enroll_time', value)}
error={form.errors.enroll_time}
/>
<DateInputSheet
label="End Date (Optional)"
value={form.values.end_time || ''}
onChange={(value) => form.setFieldValue('end_time', value)}
error={form.errors.end_time}
/>
<Group justify="flex-end" mt="lg">
<Button
variant="light"
onClick={() => router.history.back()}
disabled={isPending}
>
Cancel
</Button>
<Button
type="submit"
loading={isPending}
>
Update Tournament
</Button>
</Group>
</Stack>
</form>
</Stack>
</Box>
);
};
export default EditTournament;

View File

@@ -18,7 +18,6 @@ const useCreateTournament = () => {
toast.error('There was an issue creating your tournament. Please try again later.'); toast.error('There was an issue creating your tournament. Please try again later.');
logger.error('Error creating tournament', data); logger.error('Error creating tournament', data);
} else { } else {
toast.success('Tournament created successfully!');
logger.info('Tournament created successfully', data); logger.info('Tournament created successfully', data);
navigate({ to: '/tournaments' }); navigate({ to: '/tournaments' });
} }

View File

@@ -2,7 +2,7 @@ import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = { export const defaultHeaderConfig: HeaderConfig = {
title: 'Starter App', title: 'FLXN',
withBackButton: false, withBackButton: false,
collapsed: false, collapsed: false,
} }

View File

@@ -1,6 +1,7 @@
import { Button, TextInput } from "@mantine/core"; import { TextInput } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import useCreateUser from "../hooks/use-create-user"; import useCreateUser from "../hooks/use-create-user";
import Button from "@/components/button";
const NamePrompt = () => { const NamePrompt = () => {
const form = useForm({ const form = useForm({
@@ -41,7 +42,7 @@ const NamePrompt = () => {
key={form.key('last_name')} key={form.key('last_name')}
{...form.getInputProps('last_name')} {...form.getInputProps('last_name')}
/> />
<Button loading={isPending} type='submit' w='100%' mt='10px' variant='filled'>Create Account</Button> <Button loading={isPending} type='submit' mt='10px' variant='filled'>Create Account</Button>
</form> </form>
) )
} }

View File

@@ -1,4 +1,5 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core"; import Button from "@/components/button";
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { ChalkboardTeacherIcon } from "@phosphor-icons/react"; import { ChalkboardTeacherIcon } from "@phosphor-icons/react";
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => { const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {

View File

@@ -1,13 +1,14 @@
import { useState, FormEventHandler, useMemo } from 'react'; import { useState, FormEventHandler, useMemo } from 'react';
import { ArrowLeftIcon } from '@phosphor-icons/react'; import { ArrowLeftIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Autocomplete, Button, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core'; import { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
import ExistingPlayerButton from './existing-player-button'; import ExistingPlayerButton from './existing-player-button';
import NewPlayerButton from './new-player-button'; import NewPlayerButton from './new-player-button';
import { Player } from '@/features/players/types'; import { Player } from '@/features/players/types';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { playerQueries } from '@/features/players/queries'; import { playerQueries } from '@/features/players/queries';
import useCreateUser from '../../hooks/use-create-user'; import useCreateUser from '../../hooks/use-create-user';
import Button from '@/components/button';
enum PlayerPromptStage { enum PlayerPromptStage {
returning = 'returning', returning = 'returning',
@@ -109,7 +110,7 @@ const PlayerPrompt = () => {
value={value} value={value}
onChange={handleNewPlayerChange} onChange={handleNewPlayerChange}
/> />
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button> <Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
</form> </form>
</> : </> :
<form onSubmit={formSubmitHandler(handlePlayerSubmit)}> <form onSubmit={formSubmitHandler(handlePlayerSubmit)}>
@@ -120,7 +121,7 @@ const PlayerPrompt = () => {
onChange={handleReturningPlayerChange} onChange={handleReturningPlayerChange}
error={error} error={error}
/> />
<Button type='submit' w='100%' mt='10px' color='green' variant='filled'>Submit</Button> <Button type='submit' mt='10px' color='green' variant='filled'>Submit</Button>
</form> </form>
} }
</> </>

View File

@@ -1,4 +1,5 @@
import { Button, Center, ElementProps, SimpleGrid, Text } from "@mantine/core"; import Button from "@/components/button";
import { Center, ElementProps, SimpleGrid, Text } from "@mantine/core";
import { UserPlusIcon } from "@phosphor-icons/react"; import { UserPlusIcon } from "@phosphor-icons/react";
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => { const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {

View File

@@ -1,20 +1,36 @@
import { Box, Button, Text } from "@mantine/core"; import { Box, Button, Text, Title } from "@mantine/core";
import Header from "./header"; import Header from "./header";
import { testEvent } from "@/utils/test-event"; import { testEvent } from "@/utils/test-event";
import { Player } from "@/features/players/types"; import { Player } from "@/features/players/types";
import TeamList from "@/features/teams/components/team-list"; import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
interface ProfileProps { interface ProfileProps {
player: Player; player: Player;
} }
const Profile = ({ player }: ProfileProps) => { const Profile = ({ player }: ProfileProps) => {
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text>
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>
},
{
label: "Teams",
content: <>
<TeamList teams={player.teams || []} />
</>
}
];
return <> return <>
<Header player={player} /> <Header player={player} />
<Box m='sm' mt='lg'> <Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Teams</Text> <SwipeableTabs tabs={tabs} />
<TeamList teams={player.teams ?? []} />
</Box> </Box>
</>; </>;
}; };

View File

@@ -1,10 +1,11 @@
import { updatePlayer } from "@/features/players/server"; import { updatePlayer } from "@/features/players/server";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { Button, Stack, TextInput } from "@mantine/core" import { Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import toast from "@/lib/sonner"; import toast from "@/lib/sonner";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { Player } from "../../types"; import { Player } from "../../types";
import Button from "@/components/button";
interface NameUpdateFormProps { interface NameUpdateFormProps {
player: Player; player: Player;
@@ -50,8 +51,8 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
<Stack gap='xs'> <Stack gap='xs'>
<TextInput label='First Name' {...form.getInputProps('first_name')} /> <TextInput label='First Name' {...form.getInputProps('first_name')} />
<TextInput label='Last Name' {...form.getInputProps('last_name')} /> <TextInput label='Last Name' {...form.getInputProps('last_name')} />
<Button fullWidth loading={isPending} type='submit'>Save</Button> <Button loading={isPending} type='submit'>Save</Button>
<Button fullWidth variant='subtle' color='red' onClick={toggle}>Cancel</Button> <Button variant='subtle' color='red' onClick={toggle}>Cancel</Button>
</Stack> </Stack>
</form> </form>
) )

View File

@@ -1,13 +1,16 @@
import { setUserMetadata, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types"; import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { logger } from "."; import { logger } from ".";
import { getWebRequest } from "@tanstack/react-start/server";
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.middleware([superTokensFunctionMiddleware]) .handler(async ({ response }) => {
.handler(async ({ context }) => { const request = getWebRequest();
const { context } = await verifySuperTokensSession(request, response);
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} }; if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
try { try {

View File

@@ -4,7 +4,7 @@ import { z } from 'zod';
export interface Team { export interface Team {
id: string; id: string;
name: string; name: string;
logo_url: string; logo: string;
primary_color: string; primary_color: string;
accent_color: string; accent_color: string;
song_id: string; song_id: string;
@@ -22,7 +22,7 @@ export interface Team {
export const teamInputSchema = z.object({ export const teamInputSchema = z.object({
name: z.string().min(1, "Team name is required").max(100, "Name too long"), name: z.string().min(1, "Team name is required").max(100, "Name too long"),
logo_url: z.url("Invalid logo URL").optional(), logo: z.file("Invalid logo").optional(),
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(), primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(), 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_id: z.string().max(255).optional(),

View File

@@ -0,0 +1,23 @@
import { Flex, Title } from "@mantine/core";
import Avatar from "@/components/avatar";
import { Tournament } from "../../types";
interface HeaderProps {
tournament: Tournament;
}
const Header = ({ tournament }: HeaderProps) => {
return (
<>
<Flex px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{tournament.name}</Title>
</Flex>
</Flex>
</>
)
};
export default Header;

View File

@@ -0,0 +1,38 @@
import { Box, Divider, Text } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { Tournament } from "../../types";
import { PreviewBracket } from "@/features/bracket/components/preview";
interface ProfileProps {
tournament: Tournament;
}
const Profile = ({ tournament }: ProfileProps) => {
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>
},
{
label: "Teams",
content: <>
<TeamList teams={tournament.teams || []} />
</>
}
];
return <>
<Header tournament={tournament} />
<Box m='sm' mt='lg'>
<SwipeableTabs tabs={tabs} />
</Box>
</>;
};
export default Profile;

View File

@@ -1,7 +1,7 @@
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core" import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
import { Tournament } from "@/features/tournaments/types" import { Tournament } from "@/features/tournaments/types"
import { useMemo } from "react" import { useMemo } from "react"
import { CaretRightIcon } from "@phosphor-icons/react" import { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
import { useNavigate } from "@tanstack/react-router" import { useNavigate } from "@tanstack/react-router"
interface TournamentCardProps { interface TournamentCardProps {
@@ -26,11 +26,12 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Stack> <Stack>
<Flex align='center' gap='md'> <Flex align='center' gap='md'>
<Image <Image
src={tournament.logo_url} src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
maw={100} maw={100}
mah={100} mah={100}
fit='contain' fit='contain'
alt={tournament.name} alt={tournament.name}
fallbackSrc={"TODO"}
/> />
<Stack ta='center' mx='auto' gap='0'> <Stack ta='center' mx='auto' gap='0'>
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text> <Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>

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 { Tournament } from "../types";
interface TournamentListProps {
tournaments: Tournament[];
loading?: boolean;
}
const TournamentList = ({ tournaments, loading = false }: TournamentListProps) => {
const navigate = useNavigate();
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton height={40} width={40} />}
>
<Skeleton height={20} width={200} />
</ListItem>
))}
</List>
return <List>
{tournaments?.map((tournament) => (
<ListItem key={tournament.id}
py='xs'
icon={<Avatar radius='xs' size={40} name={`${tournament.name}`} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />}
style={{ cursor: 'pointer' }}
onClick={() => {
navigate({ to: `/tournaments/${tournament.id}` });
}}
>
<Text fw={500}>{`${tournament.name}`}</Text>
</ListItem>
))}
</List>
}
export default TournamentList;

View File

@@ -33,6 +33,24 @@ export const createTournament = createServerFn()
} }
}); });
export const updateTournament = createServerFn()
.validator(z.object({
id: z.string(),
updates: tournamentInputSchema.partial()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => {
try {
logger.info('Updating tournament', data);
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
return tournament;
} catch (error) {
logger.error('Error updating tournament', error);
return null;
}
});
export const getTournament = createServerFn() export const getTournament = createServerFn()
.validator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])

View File

@@ -7,7 +7,7 @@ export interface Tournament {
location?: string; location?: string;
desc?: string; desc?: string;
rules?: string; rules?: string;
logo_url?: string; logo?: string;
enroll_time?: string; enroll_time?: string;
start_time: string; start_time: string;
end_time?: string; end_time?: string;
@@ -22,7 +22,7 @@ export const tournamentFormSchema = z.object({
location: z.string().optional(), location: z.string().optional(),
desc: z.string().optional(), desc: z.string().optional(),
rules: z.string().optional(), rules: z.string().optional(),
logo_url: z.string().optional(), logo: z.file().optional(),
enroll_time: z.string(), enroll_time: z.string(),
start_time: z.string(), start_time: z.string(),
end_time: z.string().optional(), end_time: z.string().optional(),
@@ -34,7 +34,7 @@ export const tournamentInputSchema = z.object({
location: z.string().optional(), location: z.string().optional(),
desc: z.string().optional(), desc: z.string().optional(),
rules: z.string().optional(), rules: z.string().optional(),
logo_url: z.string().optional(), logo: z.file().optional(),
enroll_time: z.string(), enroll_time: z.string(),
start_time: z.string(), start_time: z.string(),
end_time: z.string().optional(), end_time: z.string().optional(),

View File

@@ -24,11 +24,10 @@ export function createTournamentsService(pb: PocketBase) {
const result = await pb const result = await pb
.collection("tournaments") .collection("tournaments")
.getFullList<Tournament>({ .getFullList<Tournament>({
fields: "id,name,start_time,end_time,logo_url,created", fields: "id,name,start_time,end_time,logo,created",
sort: "-created", sort: "-created",
}); });
console.log(result);
return result.map(transformTournament); return result.map(transformTournament);
}, },
async createTournament(data: TournamentInput): Promise<Tournament> { async createTournament(data: TournamentInput): Promise<Tournament> {
@@ -39,7 +38,7 @@ export function createTournamentsService(pb: PocketBase) {
}, },
async updateTournament( async updateTournament(
id: string, id: string,
data: TournamentUpdateInput data: Partial<TournamentUpdateInput>
): Promise<Tournament> { ): Promise<Tournament> {
const result = await pb const result = await pb
.collection("tournaments") .collection("tournaments")

View File

@@ -36,7 +36,7 @@ export function transformTeam(record: any): Team {
return { return {
id: record.id, id: record.id,
name: record.name, name: record.name,
logo_url: record.logo_url, logo: record.logo,
primary_color: record.primary_color, primary_color: record.primary_color,
accent_color: record.accent_color, accent_color: record.accent_color,
song_id: record.song_id, song_id: record.song_id,
@@ -67,7 +67,7 @@ export function transformTournament(record: any): Tournament {
location: record.location, location: record.location,
desc: record.desc, desc: record.desc,
rules: record.rules, rules: record.rules,
logo_url: record.logo_url, logo: record.logo,
enroll_time: record.enroll_time, enroll_time: record.enroll_time,
start_time: record.start_time, start_time: record.start_time,
end_time: record.end_time, end_time: record.end_time,

View File

@@ -10,7 +10,7 @@ import { refreshSession } from "supertokens-node/recipe/session";
const logger = new Logger('Middleware'); const logger = new Logger('Middleware');
const verifySuperTokensSession = async (request: Request, response?: ServerFnResponseType) => { export const verifySuperTokensSession = async (request: Request, response?: ServerFnResponseType) => {
const session = await getSessionForStart(request, { sessionRequired: false }); const session = await getSessionForStart(request, { sessionRequired: false });
if (session?.needsRefresh && response) { if (session?.needsRefresh && response) {
@@ -47,7 +47,7 @@ export const superTokensRequestMiddleware = createMiddleware({ type: 'request' }
if (!session.context.userAuthId) { if (!session.context.userAuthId) {
logger.error('Unauthenticated user in API call.', session.context) logger.error('Unauthenticated user in API call.', session.context)
throw new Error('Unauthenticated') throw new Error("Unauthenticated");
} }
const context = { const context = {
@@ -66,7 +66,7 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: 'function'
if (!session.context.userAuthId) { if (!session.context.userAuthId) {
logger.error('Unauthenticated user in server function.', session.context) logger.error('Unauthenticated user in server function.', session.context)
throw new Error('Unauthenticated') throw new Error("Unauthenticated");
} }
const context = { const context = {