Compare commits
5 Commits
324e1742f1
...
2b8ccf1649
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8ccf1649 | ||
|
|
936ab0ce72 | ||
|
|
466a3365f0 | ||
|
|
1015f63f7e | ||
|
|
fb1e4c3ee7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ yarn.lock
|
||||
/playwright/.cache/
|
||||
/scripts/
|
||||
/pb_data/
|
||||
/.tanstack/
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mantine/carousel": "^8.2.4",
|
||||
"@mantine/charts": "^8.2.4",
|
||||
"@mantine/core": "^8.2.4",
|
||||
"@mantine/dates": "^8.2.4",
|
||||
@@ -28,6 +29,7 @@
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"ioredis": "^5.7.0",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
48
pb_migrations/1756058328_updated_tournaments.js
Normal file
48
pb_migrations/1756058328_updated_tournaments.js
Normal 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)
|
||||
})
|
||||
45
pb_migrations/1756058447_updated_teams.js
Normal file
45
pb_migrations/1756058447_updated_teams.js
Normal 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)
|
||||
})
|
||||
@@ -23,9 +23,12 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
|
||||
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 { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
||||
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
|
||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
||||
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
|
||||
const rootServerRouteImport = createServerRootRoute()
|
||||
|
||||
@@ -89,11 +92,23 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
||||
path: '/preview',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsIdRoute =
|
||||
AuthedAdminTournamentsIdRouteImport.update({
|
||||
id: '/tournaments/$id',
|
||||
path: '/tournaments/$id',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const ApiTestServerRoute = ApiTestServerRouteImport.update({
|
||||
id: '/api/test',
|
||||
path: '/api/test',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiTournamentsUploadLogoServerRoute =
|
||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
||||
id: '/api/tournaments/upload-logo',
|
||||
path: '/api/tournaments/upload-logo',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
|
||||
id: '/api/events/$',
|
||||
path: '/api/events/$',
|
||||
@@ -104,6 +119,12 @@ const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} 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 {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -117,6 +138,7 @@ export interface FileRoutesByFullPath {
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/admin/': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -129,6 +151,7 @@ export interface FileRoutesByTo {
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/admin': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -144,6 +167,7 @@ export interface FileRoutesById {
|
||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -159,6 +183,7 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/admin/'
|
||||
| '/tournaments'
|
||||
| '/admin/tournaments/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/login'
|
||||
@@ -171,6 +196,7 @@ export interface FileRouteTypes {
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/admin'
|
||||
| '/tournaments'
|
||||
| '/admin/tournaments/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_authed'
|
||||
@@ -185,6 +211,7 @@ export interface FileRouteTypes {
|
||||
| '/_authed/tournaments/$tournamentId'
|
||||
| '/_authed/admin/'
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -196,30 +223,54 @@ export interface FileServerRoutesByFullPath {
|
||||
'/api/test': typeof ApiTestServerRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRoutesByTo {
|
||||
'/api/test': typeof ApiTestServerRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRoutesById {
|
||||
__root__: typeof rootServerRouteImport
|
||||
'/api/test': typeof ApiTestServerRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRouteTypes {
|
||||
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
|
||||
to: '/api/test' | '/api/auth/$' | '/api/events/$'
|
||||
id: '__root__' | '/api/test' | '/api/auth/$' | '/api/events/$'
|
||||
to:
|
||||
| '/api/test'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/api/test'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
fileServerRoutesById: FileServerRoutesById
|
||||
}
|
||||
export interface RootServerRouteChildren {
|
||||
ApiTestServerRoute: typeof ApiTestServerRoute
|
||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -308,6 +359,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
||||
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' {
|
||||
@@ -319,6 +377,13 @@ declare module '@tanstack/react-start/server' {
|
||||
preLoaderRoute: typeof ApiTestServerRouteImport
|
||||
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/$': {
|
||||
id: '/api/events/$'
|
||||
path: '/api/events/$'
|
||||
@@ -333,17 +398,26 @@ declare module '@tanstack/react-start/server' {
|
||||
preLoaderRoute: typeof ApiAuthSplatServerRouteImport
|
||||
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 {
|
||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
|
||||
}
|
||||
|
||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
|
||||
}
|
||||
|
||||
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
||||
@@ -385,6 +459,9 @@ const rootServerRouteChildren: RootServerRouteChildren = {
|
||||
ApiTestServerRoute: ApiTestServerRoute,
|
||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||
ApiFilesCollectionRecordIdFileServerRoute:
|
||||
ApiFilesCollectionRecordIdFileServerRoute,
|
||||
}
|
||||
export const serverRouteTree = rootServerRouteImport
|
||||
._addFileChildren(rootServerRouteChildren)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/carousel/styles.css';
|
||||
import {
|
||||
HeadContent,
|
||||
Navigate,
|
||||
@@ -12,7 +13,7 @@ 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 Providers from '@/features/core/components/providers'
|
||||
import { ColorSchemeScript, mantineHtmlProps } from '@mantine/core';
|
||||
import { HeaderConfig } from '@/features/core/types/header-config';
|
||||
|
||||
|
||||
33
src/app/routes/_authed/admin/tournaments/$id.tsx
Normal file
33
src/app/routes/_authed/admin/tournaments/$id.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,14 @@ 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 { Box, Group, Title } from '@mantine/core';
|
||||
import { useSheet } from '@/hooks/use-sheet';
|
||||
import Sheet from '@/components/sheet/sheet';
|
||||
import { Tournament } from '@/features/tournaments/types';
|
||||
import TeamList from '@/features/teams/components/team-list';
|
||||
import Button from '@/components/button';
|
||||
import Avatar from '@/components/avatar';
|
||||
import Profile from '@/features/tournaments/components/profile';
|
||||
|
||||
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -28,28 +31,8 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||
|
||||
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>
|
||||
|
||||
<Sheet
|
||||
{...sheet.props}
|
||||
title='Teams'
|
||||
>
|
||||
<TeamDrawer tournament={tournament!} />
|
||||
</Sheet>
|
||||
<Profile tournament={tournament!} />
|
||||
</Page>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Page from '@/components/page'
|
||||
import { Button, Stack } from '@mantine/core'
|
||||
import { Stack } from '@mantine/core'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
import Button from '@/components/button'
|
||||
|
||||
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
|
||||
95
src/app/routes/api/files/$collection/$recordId/$file.ts
Normal file
95
src/app/routes/api/files/$collection/$recordId/$file.ts
Normal 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',
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
115
src/app/routes/api/tournaments/upload-logo.ts
Normal file
115
src/app/routes/api/tournaments/upload-logo.ts
Normal 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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,53 +1,161 @@
|
||||
import {
|
||||
ErrorComponent,
|
||||
Link,
|
||||
rootRouteId,
|
||||
useMatch,
|
||||
useRouter,
|
||||
useNavigate,
|
||||
redirect,
|
||||
} 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) {
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
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 (
|
||||
<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()
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
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
|
||||
</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()
|
||||
}}
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon color="red" size={80} radius="xl">
|
||||
<XCircleIcon size={48} />
|
||||
</ThemeIcon>
|
||||
<Title order={2} ta="center">Access Denied</Title>
|
||||
<Text size="lg" c="dimmed" ta="center">
|
||||
You don't have permission to access this.
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ interface AvatarProps extends Omit<MantineAvatarProps, 'radius' | 'color' | 'siz
|
||||
name: string;
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
withBorder?: boolean;
|
||||
}
|
||||
|
||||
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} />
|
||||
const Avatar = ({ name, size = 35, radius = '100%', withBorder = true, ...props }: AvatarProps) => {
|
||||
return <Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
||||
<MantineAvatar alt={name} key={name} name={name} color='initials' size={size} radius={radius} w='fit-content' styles={{ image: {
|
||||
objectFit: 'contain'
|
||||
} }} {...props} />
|
||||
</Paper>
|
||||
}
|
||||
|
||||
|
||||
11
src/components/button.tsx
Normal file
11
src/components/button.tsx
Normal 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;
|
||||
78
src/components/date-input.tsx
Normal file
78
src/components/date-input.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 { useState, ReactNode} from "react";
|
||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||
import Button from "@/components/button";
|
||||
|
||||
interface SlidePanelProps {
|
||||
children: ReactNode;
|
||||
|
||||
127
src/components/swipeable-tabs.tsx
Normal file
127
src/components/swipeable-tabs.tsx
Normal 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;
|
||||
@@ -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 { LinkIcon } from "@phosphor-icons/react";
|
||||
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 { isNotEmpty } from "@mantine/form";
|
||||
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 }) => {
|
||||
|
||||
@@ -14,7 +18,6 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
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',
|
||||
},
|
||||
@@ -28,12 +31,45 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
}
|
||||
|
||||
const form = useForm(config);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createTournament, isPending } = useCreateTournament();
|
||||
|
||||
const handleSubmit = async (values: TournamentFormInput) => {
|
||||
createTournament(values, {
|
||||
onSuccess: () => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -67,12 +103,12 @@ const CreateTournament = ({ close }: { close: () => void }) => {
|
||||
{...form.getInputProps('desc')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
key={form.key('logo_url')}
|
||||
accept="image/*"
|
||||
<FileInput
|
||||
key={form.key('logo')}
|
||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||
label="Logo"
|
||||
leftSection={<LinkIcon size={16} />}
|
||||
{...form.getInputProps('logo_url')}
|
||||
{...form.getInputProps('logo')}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
|
||||
193
src/features/admin/components/edit-tournament.tsx
Normal file
193
src/features/admin/components/edit-tournament.tsx
Normal 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;
|
||||
@@ -18,7 +18,6 @@ const useCreateTournament = () => {
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isMatch, useMatches } from "@tanstack/react-router";
|
||||
import { HeaderConfig } from "../types/header-config";
|
||||
|
||||
export const defaultHeaderConfig: HeaderConfig = {
|
||||
title: 'Starter App',
|
||||
title: 'FLXN',
|
||||
withBackButton: false,
|
||||
collapsed: false,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button, TextInput } from "@mantine/core";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import useCreateUser from "../hooks/use-create-user";
|
||||
import Button from "@/components/button";
|
||||
|
||||
const NamePrompt = () => {
|
||||
const form = useForm({
|
||||
@@ -41,7 +42,7 @@ const NamePrompt = () => {
|
||||
key={form.key('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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
const ExistingPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 { Autocomplete, Divider, Flex, Text, TextInput, Title, UnstyledButton } from '@mantine/core';
|
||||
import ExistingPlayerButton from './existing-player-button';
|
||||
import NewPlayerButton from './new-player-button';
|
||||
import { Player } from '@/features/players/types';
|
||||
import { toast } from 'sonner';
|
||||
import { playerQueries } from '@/features/players/queries';
|
||||
import useCreateUser from '../../hooks/use-create-user';
|
||||
import Button from '@/components/button';
|
||||
|
||||
enum PlayerPromptStage {
|
||||
returning = 'returning',
|
||||
@@ -109,7 +110,7 @@ const PlayerPrompt = () => {
|
||||
value={value}
|
||||
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 onSubmit={formSubmitHandler(handlePlayerSubmit)}>
|
||||
@@ -120,7 +121,7 @@ const PlayerPrompt = () => {
|
||||
onChange={handleReturningPlayerChange}
|
||||
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>
|
||||
}
|
||||
</>
|
||||
|
||||
@@ -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";
|
||||
|
||||
const NewPlayerButton: React.FC<ElementProps<"button">> = ({ onClick }) => {
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { Box, Button, Text } from "@mantine/core";
|
||||
import { Box, Button, Text, Title } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import { testEvent } from "@/utils/test-event";
|
||||
import { Player } from "@/features/players/types";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
|
||||
interface ProfileProps {
|
||||
player: Player;
|
||||
}
|
||||
|
||||
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 <>
|
||||
<Header player={player} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<Text size='xl' fw={600}>Teams</Text>
|
||||
<TeamList teams={player.teams ?? []} />
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { updatePlayer } from "@/features/players/server";
|
||||
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 toast from "@/lib/sonner";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { Player } from "../../types";
|
||||
import Button from "@/components/button";
|
||||
|
||||
interface NameUpdateFormProps {
|
||||
player: Player;
|
||||
@@ -50,8 +51,8 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
||||
<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>
|
||||
<Button loading={isPending} type='submit'>Save</Button>
|
||||
<Button variant='subtle' color='red' onClick={toggle}>Cancel</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { setUserMetadata, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } 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 ".";
|
||||
import { getWebRequest } from "@tanstack/react-start/server";
|
||||
|
||||
export const fetchMe = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context }) => {
|
||||
.handler(async ({ response }) => {
|
||||
const request = getWebRequest();
|
||||
const { context } = await verifySuperTokensSession(request, response);
|
||||
|
||||
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_url: string;
|
||||
logo: string;
|
||||
primary_color: string;
|
||||
accent_color: string;
|
||||
song_id: string;
|
||||
@@ -22,7 +22,7 @@ export interface Team {
|
||||
|
||||
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(),
|
||||
logo: z.file("Invalid logo").optional(),
|
||||
primary_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
|
||||
accent_color: z.string().regex(/^#[0-9A-F]{6}$/i, "Must be valid hex color (#FF0000)").optional(),
|
||||
song_id: z.string().max(255).optional(),
|
||||
|
||||
23
src/features/tournaments/components/profile/header.tsx
Normal file
23
src/features/tournaments/components/profile/header.tsx
Normal 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;
|
||||
38
src/features/tournaments/components/profile/index.tsx
Normal file
38
src/features/tournaments/components/profile/index.tsx
Normal 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;
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
interface TournamentCardProps {
|
||||
@@ -26,11 +26,12 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||
<Stack>
|
||||
<Flex align='center' gap='md'>
|
||||
<Image
|
||||
src={tournament.logo_url}
|
||||
src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
|
||||
maw={100}
|
||||
mah={100}
|
||||
fit='contain'
|
||||
alt={tournament.name}
|
||||
fallbackSrc={"TODO"}
|
||||
/>
|
||||
<Stack ta='center' mx='auto' gap='0'>
|
||||
<Text size='lg' fw={800}>{tournament.name} <CaretRightIcon size={12} weight='bold' /></Text>
|
||||
|
||||
40
src/features/tournaments/components/tournament-list.tsx
Normal file
40
src/features/tournaments/components/tournament-list.tsx
Normal 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;
|
||||
@@ -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()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface Tournament {
|
||||
location?: string;
|
||||
desc?: string;
|
||||
rules?: string;
|
||||
logo_url?: string;
|
||||
logo?: string;
|
||||
enroll_time?: string;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
@@ -22,7 +22,7 @@ export const tournamentFormSchema = z.object({
|
||||
location: z.string().optional(),
|
||||
desc: z.string().optional(),
|
||||
rules: z.string().optional(),
|
||||
logo_url: z.string().optional(),
|
||||
logo: z.file().optional(),
|
||||
enroll_time: z.string(),
|
||||
start_time: z.string(),
|
||||
end_time: z.string().optional(),
|
||||
@@ -34,7 +34,7 @@ export const tournamentInputSchema = z.object({
|
||||
location: z.string().optional(),
|
||||
desc: z.string().optional(),
|
||||
rules: z.string().optional(),
|
||||
logo_url: z.string().optional(),
|
||||
logo: z.file().optional(),
|
||||
enroll_time: z.string(),
|
||||
start_time: z.string(),
|
||||
end_time: z.string().optional(),
|
||||
|
||||
@@ -24,11 +24,10 @@ export function createTournamentsService(pb: PocketBase) {
|
||||
const result = await pb
|
||||
.collection("tournaments")
|
||||
.getFullList<Tournament>({
|
||||
fields: "id,name,start_time,end_time,logo_url,created",
|
||||
fields: "id,name,start_time,end_time,logo,created",
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
return result.map(transformTournament);
|
||||
},
|
||||
async createTournament(data: TournamentInput): Promise<Tournament> {
|
||||
@@ -39,7 +38,7 @@ export function createTournamentsService(pb: PocketBase) {
|
||||
},
|
||||
async updateTournament(
|
||||
id: string,
|
||||
data: TournamentUpdateInput
|
||||
data: Partial<TournamentUpdateInput>
|
||||
): Promise<Tournament> {
|
||||
const result = await pb
|
||||
.collection("tournaments")
|
||||
|
||||
@@ -36,7 +36,7 @@ export function transformTeam(record: any): Team {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
logo_url: record.logo_url,
|
||||
logo: record.logo,
|
||||
primary_color: record.primary_color,
|
||||
accent_color: record.accent_color,
|
||||
song_id: record.song_id,
|
||||
@@ -67,7 +67,7 @@ export function transformTournament(record: any): Tournament {
|
||||
location: record.location,
|
||||
desc: record.desc,
|
||||
rules: record.rules,
|
||||
logo_url: record.logo_url,
|
||||
logo: record.logo,
|
||||
enroll_time: record.enroll_time,
|
||||
start_time: record.start_time,
|
||||
end_time: record.end_time,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { refreshSession } from "supertokens-node/recipe/session";
|
||||
|
||||
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 });
|
||||
|
||||
if (session?.needsRefresh && response) {
|
||||
@@ -47,7 +47,7 @@ export const superTokensRequestMiddleware = createMiddleware({ type: 'request' }
|
||||
|
||||
if (!session.context.userAuthId) {
|
||||
logger.error('Unauthenticated user in API call.', session.context)
|
||||
throw new Error('Unauthenticated')
|
||||
throw new Error("Unauthenticated");
|
||||
}
|
||||
|
||||
const context = {
|
||||
@@ -66,7 +66,7 @@ export const superTokensFunctionMiddleware = createMiddleware({ type: 'function'
|
||||
|
||||
if (!session.context.userAuthId) {
|
||||
logger.error('Unauthenticated user in server function.', session.context)
|
||||
throw new Error('Unauthenticated')
|
||||
throw new Error("Unauthenticated");
|
||||
}
|
||||
|
||||
const context = {
|
||||
|
||||
Reference in New Issue
Block a user