diff --git a/pb_migrations/1756058328_updated_tournaments.js b/pb_migrations/1756058328_updated_tournaments.js new file mode 100644 index 0000000..e8d1ded --- /dev/null +++ b/pb_migrations/1756058328_updated_tournaments.js @@ -0,0 +1,48 @@ +/// +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) +}) diff --git a/pb_migrations/1756058447_updated_teams.js b/pb_migrations/1756058447_updated_teams.js new file mode 100644 index 0000000..cdb3434 --- /dev/null +++ b/pb_migrations/1756058447_updated_teams.js @@ -0,0 +1,45 @@ +/// +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) +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index eb6fa0a..ac4a6f7 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -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 AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' 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() @@ -94,6 +96,12 @@ const ApiTestServerRoute = ApiTestServerRouteImport.update({ 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 +112,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 @@ -196,30 +210,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' { @@ -319,6 +357,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,6 +378,13 @@ 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 + } } } @@ -385,6 +437,9 @@ const rootServerRouteChildren: RootServerRouteChildren = { ApiTestServerRoute: ApiTestServerRoute, ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, + ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute, + ApiFilesCollectionRecordIdFileServerRoute: + ApiFilesCollectionRecordIdFileServerRoute, } export const serverRouteTree = rootServerRouteImport ._addFileChildren(rootServerRouteChildren) diff --git a/src/app/routes/api/files/$collection/$recordId/$file.ts b/src/app/routes/api/files/$collection/$recordId/$file.ts new file mode 100644 index 0000000..581e1fb --- /dev/null +++ b/src/app/routes/api/files/$collection/$recordId/$file.ts @@ -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', + } + }); + } +}); \ No newline at end of file diff --git a/src/app/routes/api/tournaments/upload-logo.ts b/src/app/routes/api/tournaments/upload-logo.ts new file mode 100644 index 0000000..40d45bf --- /dev/null +++ b/src/app/routes/api/tournaments/upload-logo.ts @@ -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' } + }); + } + } + }); \ No newline at end of file diff --git a/src/features/admin/components/create-tournament.tsx b/src/features/admin/components/create-tournament.tsx index 98ca0a1..115bfcc 100644 --- a/src/features/admin/components/create-tournament.tsx +++ b/src/features/admin/components/create-tournament.tsx @@ -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')} /> - } - {...form.getInputProps('logo_url')} + {...form.getInputProps('logo')} /> { 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' }); } diff --git a/src/features/players/components/profile/index.tsx b/src/features/players/components/profile/index.tsx index c58024e..a37e5b6 100644 --- a/src/features/players/components/profile/index.tsx +++ b/src/features/players/components/profile/index.tsx @@ -13,7 +13,7 @@ const Profile = ({ player }: ProfileProps) => { const tabs = [ { label: "Overview", - content: Panel 1 content + content: Stats/Badges will go here }, { label: "Teams", diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts index 0d75064..7a1b863 100644 --- a/src/features/teams/types.ts +++ b/src/features/teams/types.ts @@ -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(), diff --git a/src/features/tournaments/components/tournament-card.tsx b/src/features/tournaments/components/tournament-card.tsx index cc6053b..9f30b12 100644 --- a/src/features/tournaments/components/tournament-card.tsx +++ b/src/features/tournaments/components/tournament-card.tsx @@ -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) => { {tournament.name} {tournament.name} diff --git a/src/features/tournaments/types.ts b/src/features/tournaments/types.ts index 1119868..1393305 100644 --- a/src/features/tournaments/types.ts +++ b/src/features/tournaments/types.ts @@ -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(), diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index 879f2bf..25db756 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -24,7 +24,7 @@ export function createTournamentsService(pb: PocketBase) { const result = await pb .collection("tournaments") .getFullList({ - fields: "id,name,start_time,end_time,logo_url,created", + fields: "id,name,start_time,end_time,logo,created", sort: "-created", }); diff --git a/src/lib/pocketbase/util/transform-types.ts b/src/lib/pocketbase/util/transform-types.ts index 0f80660..7350b39 100644 --- a/src/lib/pocketbase/util/transform-types.ts +++ b/src/lib/pocketbase/util/transform-types.ts @@ -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,