tournament logo upload via api
This commit is contained in:
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)
|
||||||
|
})
|
||||||
@@ -24,8 +24,10 @@ import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$t
|
|||||||
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 { 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()
|
||||||
|
|
||||||
@@ -94,6 +96,12 @@ const ApiTestServerRoute = ApiTestServerRouteImport.update({
|
|||||||
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 +112,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
|
||||||
@@ -196,30 +210,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' {
|
||||||
@@ -319,6 +357,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,6 +378,13 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +437,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)
|
||||||
|
|||||||
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,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
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Profile = ({ player }: ProfileProps) => {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Panel 1 content</Text>
|
content: <Text p="md">Stats/Badges will go here</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teams",
|
label: "Teams",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ 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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user