diff --git a/.gitignore b/.gitignore index f9f8fe7..597e91c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ yarn.lock /blob-report/ /playwright/.cache/ /scripts/ -/pb_data/ \ No newline at end of file +/pb_data/ +/.tanstack/ \ No newline at end of file diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index ac4a6f7..2cf690e 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -23,6 +23,7 @@ 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.$' @@ -91,6 +92,12 @@ 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', @@ -131,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 @@ -143,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 @@ -158,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 @@ -173,6 +183,7 @@ export interface FileRouteTypes { | '/tournaments/$tournamentId' | '/admin/' | '/tournaments' + | '/admin/tournaments/$id' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -185,6 +196,7 @@ export interface FileRouteTypes { | '/tournaments/$tournamentId' | '/admin' | '/tournaments' + | '/admin/tournaments/$id' id: | '__root__' | '/_authed' @@ -199,6 +211,7 @@ export interface FileRouteTypes { | '/_authed/tournaments/$tournamentId' | '/_authed/admin/' | '/_authed/tournaments/' + | '/_authed/admin/tournaments/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -346,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' { @@ -391,11 +411,13 @@ declare module '@tanstack/react-start/server' { interface AuthedAdminRouteChildren { AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute + AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute } const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, + AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute, } const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index f6a9f0b..6fb35aa 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -13,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'; diff --git a/src/app/routes/_authed/admin/tournaments/$id.tsx b/src/app/routes/_authed/admin/tournaments/$id.tsx new file mode 100644 index 0000000..3b360cb --- /dev/null +++ b/src/app/routes/_authed/admin/tournaments/$id.tsx @@ -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 ( + + + + ) +} \ No newline at end of file diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx index 05cff52..e3c5b5a 100644 --- a/src/app/routes/_authed/tournaments/$tournamentId.tsx +++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx @@ -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 - - -

- {tournament?.name} -

- - - -
- - - - +
} diff --git a/src/app/routes/_authed/tournaments/index.tsx b/src/app/routes/_authed/tournaments/index.tsx index c9db7c9..b5df4ed 100644 --- a/src/app/routes/_authed/tournaments/index.tsx +++ b/src/app/routes/_authed/tournaments/index.tsx @@ -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 }) => { diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index eb0892c..2b2bd82 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -4,11 +4,12 @@ import { useMatch, useRouter, useNavigate, + redirect, } from '@tanstack/react-router' import type { ErrorComponentProps } from '@tanstack/react-router' import { Box, - Button, + Button as MantineButton, Text, Title, Stack, @@ -23,6 +24,7 @@ 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() @@ -41,8 +43,8 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { if (errorMessage.toLowerCase().includes('unauthenticated')) { toast.error('You\'ve been logged out') - navigate({ to: '/login' }) - return + router.history.push('/login') + throw redirect({ to: '/login' }) } }, [error, errorMessage, navigate]) @@ -73,13 +75,13 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { > Go Back - + @@ -137,13 +139,13 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { Try Again {isRoot ? ( - + ) : ( + + + + ); +}; \ No newline at end of file diff --git a/src/components/sheet/slide-panel/slide-panel.tsx b/src/components/sheet/slide-panel/slide-panel.tsx index 46212d3..69c64f1 100644 --- a/src/components/sheet/slide-panel/slide-panel.tsx +++ b/src/components/sheet/slide-panel/slide-panel.tsx @@ -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; diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index 602bde4..f307858 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -1,4 +1,4 @@ -import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core"; +import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core"; import { Carousel } from "@mantine/carousel"; import { useState, useEffect, ReactNode } from "react"; diff --git a/src/features/admin/components/edit-tournament.tsx b/src/features/admin/components/edit-tournament.tsx new file mode 100644 index 0000000..86d2200 --- /dev/null +++ b/src/features/admin/components/edit-tournament.tsx @@ -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 = { + 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) => + 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 ( + + + Edit Tournament + +
+ + + + + +