significant refactor
This commit is contained in:
@@ -1,16 +1,34 @@
|
||||
import { List } from "@mantine/core";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { DatabaseIcon, TreeStructureIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
DatabaseIcon,
|
||||
TreeStructureIcon,
|
||||
TrophyIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import ListButton from "@/components/list-button";
|
||||
|
||||
const AdminPage = () => {
|
||||
return (
|
||||
<List>
|
||||
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
|
||||
<ListButton label="Open Pocketbase" Icon={DatabaseIcon} onClick={() => window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
|
||||
<ListLink label="Bracket Preview" Icon={TreeStructureIcon} to="/admin/preview" />
|
||||
<ListLink
|
||||
label="Manage Tournaments"
|
||||
Icon={TrophyIcon}
|
||||
to="/admin/tournaments"
|
||||
/>
|
||||
<ListButton
|
||||
label="Open Pocketbase"
|
||||
Icon={DatabaseIcon}
|
||||
onClick={() =>
|
||||
window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")
|
||||
}
|
||||
/>
|
||||
<ListLink
|
||||
label="Bracket Preview"
|
||||
Icon={TreeStructureIcon}
|
||||
to="/admin/preview"
|
||||
/>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
export default AdminPage;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { List } from "@mantine/core";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { useTournaments } from "@/features/tournaments/queries";
|
||||
import ListLink from "@/components/list-link";
|
||||
|
||||
const ManageTournaments = () => {
|
||||
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
|
||||
const { data: tournaments } = useTournaments();
|
||||
return (
|
||||
<List>
|
||||
{tournaments.map(t => (
|
||||
{tournaments.map((t) => (
|
||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||
))}
|
||||
</List>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Logger } from "@/lib/logger";
|
||||
|
||||
export const logger = new Logger('Admin');
|
||||
export const logger = new Logger("Admin");
|
||||
|
||||
export { default as AdminPage } from './components/admin-page';
|
||||
export { default as AdminPage } from "./components/admin-page";
|
||||
|
||||
@@ -5,15 +5,15 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||
import { BracketMaps } from "../utils/bracket-maps";
|
||||
|
||||
interface BracketProps {
|
||||
winners: Match[][],
|
||||
losers?: Match[][],
|
||||
bracketMaps: BracketMaps | null
|
||||
winners: Match[][];
|
||||
losers?: Match[][];
|
||||
bracketMaps: BracketMaps | null;
|
||||
}
|
||||
|
||||
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
||||
const height = useAppShellHeight();
|
||||
|
||||
if (!bracketMaps) return <p>Bracket not available.</p>
|
||||
if (!bracketMaps) return <p>Bracket not available.</p>;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
@@ -31,14 +31,14 @@ const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
||||
</Text>
|
||||
<BracketView bracket={winners} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
{
|
||||
losers && <div>
|
||||
{losers && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<BracketView bracket={losers} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,51 @@
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { SeedBadge } from './seed-badge';
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { SeedBadge } from "./seed-badge";
|
||||
|
||||
interface MatchSlotProps {
|
||||
slot: any;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
}
|
||||
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({ slot, getParentMatchOrder }) => {
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
slot,
|
||||
getParentMatchOrder,
|
||||
}) => {
|
||||
const renderSlotContent = () => {
|
||||
if (slot?.seed) {
|
||||
return slot.team ? (
|
||||
<Text size='xs'>{slot.team.name}</Text>
|
||||
<Text size="xs">{slot.team.name}</Text>
|
||||
) : (
|
||||
<Text size='xs' c='dimmed'>Team {slot.seed}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
|
||||
return (
|
||||
<Text c='dimmed' size='xs'>
|
||||
{slot.loser ? 'Loser' : 'Winner'} of Match {getParentMatchOrder(slot.parent_lid)}
|
||||
<Text size="xs" c="dimmed">
|
||||
Team {slot.seed}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot) {
|
||||
return <Text c='dimmed' size='xs' fs='italic'>TBD</Text>;
|
||||
|
||||
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs">
|
||||
{slot.loser ? "Loser" : "Winner"} of Match{" "}
|
||||
{getParentMatchOrder(slot.parent_lid)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (slot) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs" fs="italic">
|
||||
TBD
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="stretch">
|
||||
{slot?.seed && <SeedBadge seed={slot.seed} />}
|
||||
<div style={{ flex: 1, padding: '4px 8px' }}>
|
||||
{renderSlotContent()}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: "4px 8px" }}>{renderSlotContent()}</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { bracketQueries } from "../queries";
|
||||
import { bracketQueries, useBracketPreview } from "../queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
||||
import { BracketData, Match } from "../types";
|
||||
@@ -21,9 +21,7 @@ interface PreviewTeam {
|
||||
|
||||
export const PreviewBracket: React.FC = () => {
|
||||
const [teamCount, setTeamCount] = useState(20);
|
||||
const { data, isLoading, error } = useQuery(
|
||||
bracketQueries.preview(teamCount)
|
||||
);
|
||||
const { data, isLoading, error } = useBracketPreview(teamCount);
|
||||
|
||||
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
||||
|
||||
@@ -45,7 +43,7 @@ export const PreviewBracket: React.FC = () => {
|
||||
useEffect(() => {
|
||||
if (!data || teams.length === 0) return;
|
||||
|
||||
const maps = createBracketMaps(data as BracketData);
|
||||
const maps = createBracketMaps(data);
|
||||
setBracketMaps(maps);
|
||||
|
||||
const mapBracket = (bracket: Match[][]) => {
|
||||
@@ -88,7 +86,7 @@ export const PreviewBracket: React.FC = () => {
|
||||
return (
|
||||
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
||||
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
||||
<Group gap="sm" mx='auto'>
|
||||
<Group gap="sm" mx="auto">
|
||||
<Text size="sm" c="dimmed">
|
||||
Teams:
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
|
||||
interface SeedBadgeProps {
|
||||
seed: number;
|
||||
@@ -13,17 +13,17 @@ export const SeedBadge: React.FC<SeedBadgeProps> = ({ seed }) => {
|
||||
py="4"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{
|
||||
width: '32px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--mantine-color-text)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopLeftRadius: 'var(--mantine-radius-default)',
|
||||
borderBottomLeftRadius: 'var(--mantine-radius-default)',
|
||||
width: "32px",
|
||||
textAlign: "center",
|
||||
color: "var(--mantine-color-text)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderTopLeftRadius: "var(--mantine-radius-default)",
|
||||
borderBottomLeftRadius: "var(--mantine-radius-default)",
|
||||
}}
|
||||
>
|
||||
{seed}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Flex, Text, Select, Card } from '@mantine/core';
|
||||
import { Flex, Text, Select, Card } from "@mantine/core";
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
@@ -13,11 +13,11 @@ interface SeedListProps {
|
||||
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||
const seedOptions = teams.map((_, index) => ({
|
||||
value: index.toString(),
|
||||
label: `Seed ${index + 1}`
|
||||
label: `Seed ${index + 1}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Flex direction='column' gap={8}>
|
||||
<Flex direction="column" gap={8}>
|
||||
{teams.map((team, index) => (
|
||||
<Card key={team.id} withBorder p="xs">
|
||||
<Flex align="center" gap="xs" justify="space-between">
|
||||
@@ -45,4 +45,4 @@ export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { previewBracket } from "./server";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { BracketData } from "./types";
|
||||
|
||||
const bracketKeys = {
|
||||
preview: (teams: number) => ['bracket', 'preview', teams] as const,
|
||||
preview: (teams: number) => ["bracket", "preview", teams] as const,
|
||||
};
|
||||
|
||||
export const bracketQueries = {
|
||||
preview: (teams: number) => queryOptions({
|
||||
preview: (teams: number) => ({
|
||||
queryKey: bracketKeys.preview(teams),
|
||||
queryFn: () => previewBracket({ data: teams }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useBracketPreview = (teams: number) =>
|
||||
useServerSuspenseQuery<BracketData>(bracketQueries.preview(teams));
|
||||
|
||||
@@ -2,17 +2,20 @@ import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import brackets from './utils';
|
||||
import brackets from "./utils";
|
||||
import { BracketData } from "./types";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
const logger = new Logger("Bracket Generation")
|
||||
const logger = new Logger("Bracket Generation");
|
||||
|
||||
export const previewBracket = createServerFn()
|
||||
.validator(z.number())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teams }) => {
|
||||
logger.info('Generating bracket', teams);
|
||||
if (!Object.keys(brackets).includes(teams.toString()))
|
||||
throw Error("Bracket not available")
|
||||
.handler(async ({ data: teams }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Generating bracket", teams);
|
||||
if (!Object.keys(brackets).includes(teams.toString()))
|
||||
throw Error("Bracket not available");
|
||||
return brackets[teams as keyof typeof brackets] as BracketData;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
export interface Slot {
|
||||
seed?: number;
|
||||
team?: any;
|
||||
|
||||
@@ -11,14 +11,14 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
||||
const matchByOrder = new Map<number, Match>();
|
||||
const allMatches: Match[] = [];
|
||||
|
||||
[...bracketData.winners, ...bracketData.losers].forEach(round => {
|
||||
round.forEach(match => {
|
||||
[...bracketData.winners, ...bracketData.losers].forEach((round) => {
|
||||
round.forEach((match) => {
|
||||
matchByLid.set(match.lid, match);
|
||||
|
||||
|
||||
if (match.order !== null && match.order !== undefined) {
|
||||
matchByOrder.set(match.order, match);
|
||||
}
|
||||
|
||||
|
||||
allMatches.push(match);
|
||||
});
|
||||
});
|
||||
@@ -26,14 +26,20 @@ export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
||||
return {
|
||||
matchByLid,
|
||||
matchByOrder,
|
||||
allMatches
|
||||
allMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMatchByLid(maps: BracketMaps, lid: number): Match | undefined {
|
||||
export function getMatchByLid(
|
||||
maps: BracketMaps,
|
||||
lid: number
|
||||
): Match | undefined {
|
||||
return maps.matchByLid.get(lid);
|
||||
}
|
||||
|
||||
export function getMatchByOrder(maps: BracketMaps, order: number): Match | undefined {
|
||||
export function getMatchByOrder(
|
||||
maps: BracketMaps,
|
||||
order: number
|
||||
): Match | undefined {
|
||||
return maps.matchByOrder.get(order);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
||||
*/
|
||||
import b12 from '../../../../scripts/brackets/12.json';
|
||||
import b13 from '../../../../scripts/brackets/13.json';
|
||||
import b14 from '../../../../scripts/brackets/14.json';
|
||||
import b15 from '../../../../scripts/brackets/15.json';
|
||||
import b16 from '../../../../scripts/brackets/16.json';
|
||||
import b17 from '../../../../scripts/brackets/17.json';
|
||||
import b18 from '../../../../scripts/brackets/18.json';
|
||||
import b19 from '../../../../scripts/brackets/19.json';
|
||||
import b20 from '../../../../scripts/brackets/20.json';
|
||||
import b12 from "../../../../scripts/brackets/12.json";
|
||||
import b13 from "../../../../scripts/brackets/13.json";
|
||||
import b14 from "../../../../scripts/brackets/14.json";
|
||||
import b15 from "../../../../scripts/brackets/15.json";
|
||||
import b16 from "../../../../scripts/brackets/16.json";
|
||||
import b17 from "../../../../scripts/brackets/17.json";
|
||||
import b18 from "../../../../scripts/brackets/18.json";
|
||||
import b19 from "../../../../scripts/brackets/19.json";
|
||||
import b20 from "../../../../scripts/brackets/20.json";
|
||||
|
||||
export default {
|
||||
12: b12,
|
||||
13: b13,
|
||||
14: b14,
|
||||
15: b15,
|
||||
16: b16,
|
||||
17: b17,
|
||||
18: b18,
|
||||
19: b19,
|
||||
20: b20,
|
||||
}
|
||||
12: b12,
|
||||
13: b13,
|
||||
14: b14,
|
||||
15: b15,
|
||||
16: b16,
|
||||
17: b17,
|
||||
18: b18,
|
||||
19: b19,
|
||||
20: b20,
|
||||
};
|
||||
|
||||
@@ -8,11 +8,12 @@ interface NavLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
Icon: Icon;
|
||||
include?: string[];
|
||||
}
|
||||
|
||||
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
|
||||
export const NavLink = ({ href, label, Icon, include }: NavLinkProps) => {
|
||||
const router = useRouterState();
|
||||
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
|
||||
const isActive = useMemo(() => (router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/')) || include?.includes(router.location.pathname), [router.location.pathname, href]);
|
||||
|
||||
return (
|
||||
<Box component={Link} to={href}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLinks } from "../hooks/use-links";
|
||||
const Navbar = () => {
|
||||
const { user, roles } = useAuth()
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const links = useLinks(user?.id, roles);
|
||||
|
||||
if (isMobile) return (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from "react";
|
||||
|
||||
export const useLinks = (userId: string | undefined, roles: string[]) =>
|
||||
useMemo(() => {
|
||||
if (!userId) throw new Error("userId is undefined")
|
||||
const links = [
|
||||
{
|
||||
label: 'Home',
|
||||
@@ -23,7 +22,8 @@ export const useLinks = (userId: string | undefined, roles: string[]) =>
|
||||
{
|
||||
label: 'Profile',
|
||||
href: `/profile/${userId}`,
|
||||
Icon: UserCircleIcon
|
||||
Icon: UserCircleIcon,
|
||||
include: ['/settings']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, FormEventHandler, useMemo } from 'react';
|
||||
import { ArrowLeftIcon } from '@phosphor-icons/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
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 toast from '@/lib/sonner'
|
||||
import { useUnassociatedPlayers } from '@/features/players/queries';
|
||||
import useCreateUser from '../../hooks/use-create-user';
|
||||
import Button from '@/components/button';
|
||||
|
||||
@@ -17,7 +16,7 @@ enum PlayerPromptStage {
|
||||
|
||||
const PlayerPrompt = () => {
|
||||
const [stage, setStage] = useState<PlayerPromptStage>();
|
||||
const playersQuery = useQuery(playerQueries.unassociated());
|
||||
const playersQuery = useUnassociatedPlayers();
|
||||
const { mutate: createUser, isPending } = useCreateUser();
|
||||
const players = playersQuery.data;
|
||||
const [player, setPlayer] = useState<Player>();
|
||||
|
||||
@@ -2,8 +2,8 @@ import { consumeCode } from "supertokens-web-js/recipe/passwordless";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchMe } from "@/features/players/server";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { authQueryConfig } from "@/contexts/auth-context";
|
||||
import toast from '@/lib/sonner'
|
||||
import { playerKeys } from "@/features/players/queries";
|
||||
|
||||
const useConsumeCode = (onWrongCode: () => void) => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,9 +13,9 @@ const useConsumeCode = (onWrongCode: () => void) => {
|
||||
mutationFn: (code: string) => consumeCode({ userInputCode: code }),
|
||||
onSuccess: async (data) => {
|
||||
if (data.status === 'OK') {
|
||||
const data = await fetchMe();
|
||||
queryClient.setQueryData(authQueryConfig.queryKey, data);
|
||||
if (!data || !data.user) {
|
||||
const response = await fetchMe();
|
||||
queryClient.setQueryData(playerKeys.auth, data);
|
||||
if (!response.success || !response.data.user) {
|
||||
navigate({ to: '/login', search: { stage: 'name' } });
|
||||
} else {
|
||||
toast.success('Successfully logged in. Welcome back!');
|
||||
|
||||
@@ -1,41 +1,26 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { authQueryConfig } from "@/contexts/auth-context";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { associatePlayer, createPlayer } from "@/features/players/server";
|
||||
import toast from '@/lib/sonner';
|
||||
import { playerKeys } from "@/features/players/queries";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useCreateUser = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { first_name: string, last_name: string } | string) => {
|
||||
if (typeof data === 'string') {
|
||||
return associatePlayer({ data });
|
||||
} else {
|
||||
return createPlayer({ data });
|
||||
}
|
||||
},
|
||||
return useServerMutation({
|
||||
mutationFn: (data: { first_name: string, last_name: string } | string) =>
|
||||
typeof data === 'string' ?
|
||||
associatePlayer({ data })
|
||||
: createPlayer({ data }),
|
||||
successMessage: 'Account created successfully!',
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
toast.error('There was an issue creating your account. Please try again later.');
|
||||
navigate({ to: '/login' });
|
||||
} else {
|
||||
queryClient.setQueryData(authQueryConfig.queryKey, (old: any) => ({
|
||||
queryClient.setQueryData(playerKeys.auth, (old: any) => ({
|
||||
...old,
|
||||
user: data
|
||||
}));
|
||||
toast.success('Account created successfully!');
|
||||
navigate({ to: '/' });
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('An unexpected error occurred when trying to create an account. Please try again later.');
|
||||
}
|
||||
},
|
||||
navigate({ to: '/' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Box, Button, Text, Title } from "@mantine/core";
|
||||
import { Box, Text } 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";
|
||||
import { usePlayer } from "../../queries";
|
||||
|
||||
interface ProfileProps {
|
||||
player: Player;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const Profile = ({ player }: ProfileProps) => {
|
||||
const Profile = ({ id }: ProfileProps) => {
|
||||
const { data: player } = usePlayer(id);
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
@@ -20,10 +20,8 @@ const Profile = ({ player }: ProfileProps) => {
|
||||
content: <Text p="md">Matches feed will go here</Text>
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <>
|
||||
<TeamList teams={player.teams || []} />
|
||||
</>
|
||||
label: "Teams",
|
||||
content: <Text p="md">Teams will go here</Text>
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { updatePlayer } from "@/features/players/server";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
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";
|
||||
import { useOptimisticMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { playerKeys } from "../../queries";
|
||||
|
||||
interface NameUpdateFormProps {
|
||||
player: Player;
|
||||
@@ -13,8 +12,6 @@ interface NameUpdateFormProps {
|
||||
}
|
||||
|
||||
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
first_name: player.first_name,
|
||||
@@ -23,30 +20,32 @@ const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
|
||||
validate: {
|
||||
first_name: (value: string | undefined) => {
|
||||
if (!value || value.length === 0) return "First name is required";
|
||||
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
||||
return "First name must be 3-20 characters long and contain only letters";
|
||||
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
|
||||
return "First name must be 2-20 characters long and contain only letters";
|
||||
},
|
||||
last_name: (value: string | undefined) => {
|
||||
if (!value || value.length === 0) return "Last name is required";
|
||||
if (!/^[a-zA-Z\s]{3,20}$/.test(value))
|
||||
return "Last name must be 3-20 characters long and contain only letters";
|
||||
if (!/^[a-zA-Z\s]{2,20}$/.test(value))
|
||||
return "Last name must be 2-20 characters long and contain only letters";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateName, isPending } = useMutation({
|
||||
const { mutate: updateName, isPending } = useOptimisticMutation({
|
||||
mutationFn: async (data: { first_name: string; last_name: string }) =>
|
||||
await updatePlayer({ data }),
|
||||
onSuccess: () => {
|
||||
toggle();
|
||||
toast.success("Name updated successfully!");
|
||||
router.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
"There was an issue updating your name. Please try again later."
|
||||
);
|
||||
onSuccess: toggle,
|
||||
onError: toggle,
|
||||
successMessage: "Name updated successfully!",
|
||||
optimisticUpdate: (oldData, variables) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
first_name: variables.first_name,
|
||||
last_name: variables.last_name,
|
||||
};
|
||||
},
|
||||
queryKey: playerKeys.details(player.id)
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { listPlayers, getPlayer, getUnassociatedPlayers } from "./server";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { listPlayers, getPlayer, getUnassociatedPlayers, fetchMe } from "./server";
|
||||
|
||||
const playerKeys = {
|
||||
list: ['players', 'list'] as const,
|
||||
details: (id: string) => ['players', 'details', id] as const,
|
||||
unassociated: ['players','unassociated'] as const,
|
||||
export const playerKeys = {
|
||||
auth: ['auth'],
|
||||
list: ['players', 'list'],
|
||||
details: (id: string) => ['players', 'details', id],
|
||||
unassociated: ['players','unassociated'],
|
||||
};
|
||||
|
||||
export const playerQueries = {
|
||||
list: () => queryOptions({
|
||||
auth: () => ({
|
||||
queryKey: playerKeys.auth,
|
||||
queryFn: async () => await fetchMe()
|
||||
}),
|
||||
list: () => ({
|
||||
queryKey: playerKeys.list,
|
||||
queryFn: listPlayers,
|
||||
queryFn: async () => await listPlayers()
|
||||
}),
|
||||
details: (id: string) => queryOptions({
|
||||
details: (id: string) => ({
|
||||
queryKey: playerKeys.details(id),
|
||||
queryFn: () => getPlayer({ data: id }),
|
||||
queryFn: async () => await getPlayer({ data: id })
|
||||
}),
|
||||
unassociated: () => queryOptions({
|
||||
unassociated: () => ({
|
||||
queryKey: playerKeys.unassociated,
|
||||
queryFn: getUnassociatedPlayers,
|
||||
queryFn: async () => await getUnassociatedPlayers()
|
||||
}),
|
||||
};
|
||||
|
||||
export const useMe = () => {
|
||||
const { queryKey, queryFn } = playerQueries.auth();
|
||||
return useServerSuspenseQuery({
|
||||
queryKey,
|
||||
queryFn,
|
||||
options: {
|
||||
staleTime: 0,
|
||||
refetchOnMount: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const usePlayer = (id: string) =>
|
||||
useServerSuspenseQuery(playerQueries.details(id));
|
||||
|
||||
export const usePlayers = () =>
|
||||
useServerSuspenseQuery(playerQueries.list());
|
||||
|
||||
export const useUnassociatedPlayers = () =>
|
||||
useServerSuspenseQuery(playerQueries.unassociated());
|
||||
@@ -1,60 +1,48 @@
|
||||
import { setUserMetadata, superTokensFunctionMiddleware, verifySuperTokensSession } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { playerInputSchema, playerUpdateSchema } from "@/features/players/types";
|
||||
import { Player, 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";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
export const fetchMe = createServerFn()
|
||||
.handler(async ({ response }) => {
|
||||
const request = getWebRequest();
|
||||
const { context } = await verifySuperTokensSession(request, response);
|
||||
.handler(async ({ response }) =>
|
||||
toServerResult(async () => {
|
||||
const request = getWebRequest();
|
||||
const { context } = await verifySuperTokensSession(request, response);
|
||||
|
||||
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
||||
if (!context || !context.userAuthId) return { user: undefined, roles: [], metadata: {} };
|
||||
|
||||
try {
|
||||
await pbAdmin.authPromise;
|
||||
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
logger.info('Fetched player', result);
|
||||
return {
|
||||
user: result || undefined,
|
||||
roles: context.roles,
|
||||
metadata: context.metadata
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching player:', error);
|
||||
return { user: undefined, roles: context.roles, metadata: context.metadata };
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const getPlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const player = await pbAdmin.getPlayer(data);
|
||||
return player;
|
||||
} catch (error) {
|
||||
logger.error('Error getting player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||
);
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
// Find the player by authId first
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
|
||||
if (!existing) return;
|
||||
|
||||
// Update the player
|
||||
const updatedPlayer = await pbAdmin.updatePlayer(
|
||||
existing.id!,
|
||||
{
|
||||
@@ -68,20 +56,17 @@ export const updatePlayer = createServerFn()
|
||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name } });
|
||||
|
||||
return updatedPlayer;
|
||||
} catch (error) {
|
||||
logger.error('Error updating player name', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
|
||||
if (existing) return;
|
||||
|
||||
@@ -96,20 +81,17 @@ export const createPlayer = createServerFn()
|
||||
await setUserMetadata({ data: { first_name: data.first_name, last_name: data.last_name, player_id: newPlayer?.id?.toString() } });
|
||||
logger.info('Created player', newPlayer);
|
||||
return newPlayer;
|
||||
} catch (error) {
|
||||
logger.error('Error creating player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
if (!userAuthId) return;
|
||||
|
||||
try {
|
||||
await pbAdmin.updatePlayer(data, {
|
||||
auth_id: userAuthId
|
||||
});
|
||||
@@ -119,30 +101,17 @@ export const associatePlayer = createServerFn()
|
||||
const player = await pbAdmin.getPlayer(data);
|
||||
logger.info('Associated player', player);
|
||||
return player;
|
||||
} catch (error) {
|
||||
logger.error('Error associating player', error);
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const listPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () => {
|
||||
try {
|
||||
return await pbAdmin.listPlayers();
|
||||
} catch (error) {
|
||||
logger.error('Error listing players', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.listPlayers)
|
||||
);
|
||||
|
||||
export const getUnassociatedPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () => {
|
||||
try {
|
||||
return await pbAdmin.getUnassociatedPlayers();
|
||||
} catch (error) {
|
||||
logger.error('Error getting unassociated players', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.getUnassociatedPlayers)
|
||||
);
|
||||
@@ -13,8 +13,8 @@ export interface Player {
|
||||
|
||||
export const playerInputSchema = z.object({
|
||||
auth_id: z.string().optional(),
|
||||
first_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 3-20 characters long and contain only letters and spaces"),
|
||||
last_name: z.string().min(3).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 3-20 characters long and contain only letters and spaces"),
|
||||
first_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "First name must be 2-20 characters long and contain only letters and spaces"),
|
||||
last_name: z.string().min(2).max(20).regex(/^[a-zA-Z0-9\s]+$/, "Last name must be 2-20 characters long and contain only letters and spaces"),
|
||||
});
|
||||
|
||||
export const playerUpdateSchema = playerInputSchema.partial();
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { Group, List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface TeamListItemProps { team: Team }
|
||||
interface TeamListItemProps {
|
||||
team: Team;
|
||||
}
|
||||
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
const playerNames = useMemo(() => team.players?.map(p => `${p.first_name} ${p.last_name}`) || [], [team.players]);
|
||||
const playerNames = useMemo(
|
||||
() => team.players?.map((p) => `${p.first_name} ${p.last_name}`) || [],
|
||||
[team.players]
|
||||
);
|
||||
|
||||
return <>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{`${team.name}`}</Text>
|
||||
{
|
||||
playerNames.map(name => <Text size='xs' c='dimmed'>{name}</Text>)
|
||||
}
|
||||
</Stack>
|
||||
</>
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<Stack gap={0}>
|
||||
<Text fw={500}>{`${team.name}`}</Text>
|
||||
{playerNames.map((name) => (
|
||||
<Text size="xs" c="dimmed">
|
||||
{name}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface TeamListProps {
|
||||
teams: Team[];
|
||||
@@ -27,30 +36,41 @@ interface TeamListProps {
|
||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = useCallback((teamId: string) =>
|
||||
navigate({ to: `/teams/${teamId}` }), [navigate]);
|
||||
const handleClick = useCallback(
|
||||
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
|
||||
[navigate]
|
||||
);
|
||||
|
||||
if (loading) return <List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
|
||||
>
|
||||
<Skeleton height={35} width={200} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
return <List>
|
||||
{teams?.map((team) => (
|
||||
<ListItem key={team.id}
|
||||
py='xs'
|
||||
icon={<Avatar radius='sm' size={40} name={`${team.name}`} />}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleClick(team.id)}
|
||||
if (loading)
|
||||
return (
|
||||
<List>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem
|
||||
key={`skeleton-${i}`}
|
||||
py="xs"
|
||||
icon={<Skeleton height={40} width={40} />}
|
||||
>
|
||||
<TeamListItem team={team} />
|
||||
<Skeleton height={35} width={200} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
{teams?.map((team) => (
|
||||
<ListItem
|
||||
key={team.id}
|
||||
py="xs"
|
||||
icon={<Avatar radius="sm" size={40} name={`${team.name}`} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => handleClick(team.id)}
|
||||
>
|
||||
<TeamListItem team={team} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
}
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamList;
|
||||
|
||||
@@ -3,20 +3,23 @@ import Avatar from "@/components/avatar";
|
||||
import { Team } from "../../types";
|
||||
|
||||
interface HeaderProps {
|
||||
team: Team;
|
||||
name: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
const Header = ({ team }: HeaderProps) => {
|
||||
const Header = ({ name, logo }: HeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
||||
<Avatar radius='sm' name={team.name} size={125} />
|
||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||
<Title ta='center' order={2}>{team.name}</Title>
|
||||
<Flex px="xl" w="100%" align="self-end" gap="md">
|
||||
<Avatar radius="sm" name={name} size={125} />
|
||||
<Flex align="center" justify="center" gap={4} pb={20} w="100%">
|
||||
<Title ta="center" order={2}>
|
||||
{name}
|
||||
</Title>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import Header from "./header";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
import { Team } from "../../types";
|
||||
import PlayerList from "@/features/players/components/player-list";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||
import { useTeam } from "../../queries";
|
||||
|
||||
interface ProfileProps {
|
||||
team: Team;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const TeamProfile = ({ team }: ProfileProps) => {
|
||||
console.log(team);
|
||||
const TeamProfile = ({ id }: ProfileProps) => {
|
||||
const { data: team } = useTeam(id);
|
||||
if (!team) return <Text p="md">Team not found</Text>;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <Text p="md">Stats/Badges will go here</Text>
|
||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Text p="md">Matches feed will go here</Text>
|
||||
content: <Text p="md">Matches feed will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Tournaments",
|
||||
content: <>
|
||||
<TournamentList tournaments={team.tournaments || []} />
|
||||
</>
|
||||
}
|
||||
label: "Tournaments",
|
||||
content: (
|
||||
<>
|
||||
<TournamentList tournaments={team.tournaments || []} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
return <>
|
||||
<Header team={team} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<Header name={team.name} logo={team.logo} />
|
||||
<Box m="sm" mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamProfile;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { getTeam } from "./server";
|
||||
|
||||
const teamKeys = {
|
||||
export const teamKeys = {
|
||||
details: (id: string) => ['teams', 'details', id] as const,
|
||||
};
|
||||
|
||||
export const teamQueries = {
|
||||
details: (id: string) => queryOptions({
|
||||
details: (id: string) => ({
|
||||
queryKey: teamKeys.details(id),
|
||||
queryFn: () => getTeam({ data: id }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useTeam = (id: string) =>
|
||||
useServerSuspenseQuery(teamQueries.details(id));
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from ".";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
export const getTeam = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) => {
|
||||
logger.info('Getting team', teamId);
|
||||
return await pbAdmin.getTeam(teamId);
|
||||
});
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Player } from "@/features/players/types";
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
import { Tournament } from "../tournaments/types";
|
||||
|
||||
export interface Team {
|
||||
@@ -22,28 +22,36 @@ export interface Team {
|
||||
tournaments: Tournament[];
|
||||
}
|
||||
|
||||
export const teamInputSchema = z.object({
|
||||
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
|
||||
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(),
|
||||
song_name: z.string().max(255).optional(),
|
||||
song_artist: z.string().max(255).optional(),
|
||||
song_album: z.string().max(255).optional(),
|
||||
song_year: z.number().int().optional(),
|
||||
song_start: z.number().int().optional(),
|
||||
song_end: z.number().int().optional(),
|
||||
song_image_url: z.url("Invalid song image URL").optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.song_start && data.song_end) {
|
||||
return data.song_end > data.song_start;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Song end time must be after start time", path: ["song_end"] }
|
||||
);
|
||||
export const teamInputSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Team name is required").max(100, "Name too long"),
|
||||
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(),
|
||||
song_name: z.string().max(255).optional(),
|
||||
song_artist: z.string().max(255).optional(),
|
||||
song_album: z.string().max(255).optional(),
|
||||
song_year: z.number().int().optional(),
|
||||
song_start: z.number().int().optional(),
|
||||
song_end: z.number().int().optional(),
|
||||
song_image_url: z.url("Invalid song image URL").optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.song_start && data.song_end) {
|
||||
return data.song_end > data.song_start;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{ message: "Song end time must be after start time", path: ["song_end"] }
|
||||
);
|
||||
|
||||
export type TeamInput = z.infer<typeof teamInputSchema>;
|
||||
export type TeamUpdateInput = Partial<TeamInput>;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Autocomplete, Stack, ActionIcon, Text, Group, Loader } from "@mantine/core";
|
||||
import {
|
||||
Autocomplete,
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import { tournamentQueries } from "../queries";
|
||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||
import useEnrollTeam from "../hooks/use-enroll-team";
|
||||
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
||||
import Avatar from "@/components/avatar";
|
||||
@@ -12,13 +18,17 @@ interface EditEnrolledTeamsProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
||||
interface TeamItemProps {
|
||||
team: Team;
|
||||
onUnenroll: (teamId: string) => void;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const playerNames = useMemo(() =>
|
||||
team.players?.map(p => `${p.first_name} ${p.last_name}`).join(", ") || "",
|
||||
}
|
||||
|
||||
const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
||||
const playerNames = useMemo(
|
||||
() =>
|
||||
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
|
||||
"",
|
||||
[team.players]
|
||||
);
|
||||
|
||||
@@ -26,9 +36,13 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||
<Avatar size={32} name={team.name} />
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fw={500} truncate>{team.name}</Text>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
{playerNames && (
|
||||
<Text size="xs" c="dimmed" truncate>{playerNames}</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{playerNames}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<ActionIcon
|
||||
@@ -47,30 +61,43 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: {
|
||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: tournament, isLoading: tournamentLoading } =
|
||||
useQuery(tournamentQueries.details(tournamentId));
|
||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||
useQuery(tournamentQueries.unenrolled(tournamentId));
|
||||
const { data: tournament, isLoading: tournamentLoading } =
|
||||
useTournament(tournamentId);
|
||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||
useUnenrolledTeams(tournamentId);
|
||||
|
||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||
|
||||
const autocompleteData = useMemo(() =>
|
||||
unenrolledTeams.map((team: Team) => ({ value: team.id, label: team.name })),
|
||||
const autocompleteData = useMemo(
|
||||
() =>
|
||||
unenrolledTeams.map((team: Team) => ({
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
})),
|
||||
[unenrolledTeams]
|
||||
);
|
||||
|
||||
const handleEnrollTeam = useCallback((teamId: string) => {
|
||||
enrollTeam({ tournamentId, teamId }, {
|
||||
onSuccess: () => {
|
||||
setSearch("");
|
||||
}
|
||||
});
|
||||
}, [enrollTeam, tournamentId, setSearch]);
|
||||
const handleEnrollTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
enrollTeam(
|
||||
{ tournamentId, teamId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSearch("");
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[enrollTeam, tournamentId, setSearch]
|
||||
);
|
||||
|
||||
const handleUnenrollTeam = useCallback((teamId: string) => {
|
||||
unenrollTeam({ tournamentId, teamId });
|
||||
}, [unenrollTeam, tournamentId]);
|
||||
const handleUnenrollTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
unenrollTeam({ tournamentId, teamId });
|
||||
},
|
||||
[unenrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const isLoading = tournamentLoading || unenrolledLoading;
|
||||
const enrolledTeams = tournament?.teams || [];
|
||||
@@ -79,7 +106,9 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
return (
|
||||
<Stack gap="lg" w="100%">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm">Add Team</Text>
|
||||
<Text fw={600} size="sm">
|
||||
Add Team
|
||||
</Text>
|
||||
<Autocomplete
|
||||
placeholder="Search for teams to enroll..."
|
||||
data={autocompleteData}
|
||||
@@ -95,18 +124,26 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">Enrolled Teams</Text>
|
||||
<Text size="xs" c="dimmed">{enrolledTeams.length} teams</Text>
|
||||
<Text fw={600} size="sm">
|
||||
Enrolled Teams
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{enrolledTeams.length} teams
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !hasEnrolledTeams ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">No teams enrolled yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No teams enrolled yet
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledTeams.map((team: Team) => (
|
||||
<TeamItem
|
||||
<TeamItem
|
||||
key={team.id}
|
||||
team={team}
|
||||
onUnenroll={handleUnenrollTeam}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "../queries";
|
||||
import { useTournament } from "../queries";
|
||||
import { List } from "@mantine/core";
|
||||
import ListButton from "@/components/list-button";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import TournamentForm from "./tournament-form";
|
||||
import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
HardDrivesIcon,
|
||||
PencilLineIcon,
|
||||
UsersThreeIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||
|
||||
@@ -13,26 +16,52 @@ interface ManageTournamentProps {
|
||||
}
|
||||
|
||||
const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
const { data: tournament } = useSuspenseQuery(
|
||||
tournamentQueries.details(tournamentId)
|
||||
);
|
||||
const { data: tournament } = useTournament(tournamentId);
|
||||
|
||||
if (!tournament) throw new Error("Tournament not found.");
|
||||
const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
|
||||
const { isOpen: editRulesOpened, open: openEditRules, close: closeEditRules } = useSheet();
|
||||
const { isOpen: editTeamsOpened, open: openEditTeams, close: closeEditTeams } = useSheet();
|
||||
const {
|
||||
isOpen: editTournamentOpened,
|
||||
open: openEditTournament,
|
||||
close: closeEditTournament,
|
||||
} = useSheet();
|
||||
const {
|
||||
isOpen: editRulesOpened,
|
||||
open: openEditRules,
|
||||
close: closeEditRules,
|
||||
} = useSheet();
|
||||
const {
|
||||
isOpen: editTeamsOpened,
|
||||
open: openEditTeams,
|
||||
close: closeEditTeams,
|
||||
} = useSheet();
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<ListButton label="Edit Tournament" Icon={HardDrivesIcon} onClick={openEditTournament} />
|
||||
<ListButton label="Edit Rules" Icon={PencilLineIcon} onClick={openEditRules} />
|
||||
<ListButton label="Edit Enrolled Teams" Icon={UsersThreeIcon} onClick={openEditTeams} />
|
||||
<ListButton
|
||||
label="Edit Tournament"
|
||||
Icon={HardDrivesIcon}
|
||||
onClick={openEditTournament}
|
||||
/>
|
||||
<ListButton
|
||||
label="Edit Rules"
|
||||
Icon={PencilLineIcon}
|
||||
onClick={openEditRules}
|
||||
/>
|
||||
<ListButton
|
||||
label="Edit Enrolled Teams"
|
||||
Icon={UsersThreeIcon}
|
||||
onClick={openEditTeams}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Sheet title="Edit Tournament" opened={editTournamentOpened} onChange={closeEditTournament}>
|
||||
<TournamentForm
|
||||
tournamentId={tournament.id}
|
||||
<Sheet
|
||||
title="Edit Tournament"
|
||||
opened={editTournamentOpened}
|
||||
onChange={closeEditTournament}
|
||||
>
|
||||
<TournamentForm
|
||||
tournamentId={tournament.id}
|
||||
initialValues={{
|
||||
name: tournament.name,
|
||||
location: tournament.location,
|
||||
@@ -40,20 +69,28 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
start_time: tournament.start_time,
|
||||
enroll_time: tournament.enroll_time,
|
||||
end_time: tournament.end_time,
|
||||
}}
|
||||
close={closeEditTournament}
|
||||
}}
|
||||
close={closeEditTournament}
|
||||
/>
|
||||
</Sheet>
|
||||
|
||||
<Sheet title="Edit Rules" opened={editRulesOpened} onChange={closeEditRules}>
|
||||
<Sheet
|
||||
title="Edit Rules"
|
||||
opened={editRulesOpened}
|
||||
onChange={closeEditRules}
|
||||
>
|
||||
<p>Test</p>
|
||||
</Sheet>
|
||||
|
||||
<Sheet title="Edit Enrolled Teams" opened={editTeamsOpened} onChange={closeEditTeams}>
|
||||
<Sheet
|
||||
title="Edit Enrolled Teams"
|
||||
opened={editTeamsOpened}
|
||||
onChange={closeEditTeams}
|
||||
>
|
||||
<EditEnrolledTeams tournamentId={tournamentId} />
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageTournament;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Box, Divider, Text } from "@mantine/core";
|
||||
import { Box, 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";
|
||||
import { useTournament } from "../../queries";
|
||||
|
||||
interface ProfileProps {
|
||||
tournament: Tournament;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const Profile = ({ tournament }: ProfileProps) => {
|
||||
const Profile = ({ id }: ProfileProps) => {
|
||||
const { data: tournament } = useTournament(id);
|
||||
if (!tournament) return null;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
|
||||
@@ -1,50 +1,73 @@
|
||||
import { Badge, Card, Text, Image, Stack, Flex } from "@mantine/core"
|
||||
import { Tournament } from "@/features/tournaments/types"
|
||||
import { useMemo } from "react"
|
||||
import { CaretRightIcon, TrophyIcon } from "@phosphor-icons/react"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
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 { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
interface TournamentCardProps {
|
||||
tournament: Tournament
|
||||
tournament: Tournament;
|
||||
}
|
||||
|
||||
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||
const navigate = useNavigate({ from: '/tournaments/$tournamentId' })
|
||||
const navigate = useNavigate();
|
||||
const displayDate = useMemo(() => {
|
||||
if (!tournament.start_time) return null
|
||||
const date = new Date(tournament.start_time)
|
||||
if (isNaN(date.getTime())) return null
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}, [tournament.start_time])
|
||||
if (!tournament.start_time) return null;
|
||||
const date = new Date(tournament.start_time);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}, [tournament.start_time]);
|
||||
|
||||
return (
|
||||
<Card shadow="sm" padding="lg" radius="md" withBorder style={{ cursor: 'pointer' }} onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}>
|
||||
<Card
|
||||
shadow="sm"
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate({ to: `/tournaments/${tournament.id}` })}
|
||||
>
|
||||
<Stack>
|
||||
<Flex align='center' gap='md'>
|
||||
<Flex align="center" gap="md">
|
||||
<Image
|
||||
src={tournament.logo ? `/api/files/tournaments/${tournament.id}/${tournament.logo}` : undefined}
|
||||
maw={100}
|
||||
mah={100}
|
||||
fit='contain'
|
||||
fit="contain"
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
: undefined
|
||||
}
|
||||
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>
|
||||
{displayDate && <Text c='dimmed' size='xs' fw={600}>{displayDate}</Text>}
|
||||
<Stack gap={4} mt={4}>
|
||||
{ /* TODO: Add medalists when data is available */}
|
||||
<Badge variant='dot' color='gold'>Longer Team Name Goes Here</Badge>
|
||||
<Badge variant='dot' color='silver'>Some Team</Badge>
|
||||
<Badge variant='dot' color='orange'>Medium Team Name</Badge>
|
||||
</Stack>
|
||||
/>
|
||||
<Stack ta="center" mx="auto" gap="0">
|
||||
<Text size="lg" fw={800}>
|
||||
{tournament.name} <CaretRightIcon size={12} weight="bold" />
|
||||
</Text>
|
||||
{displayDate && (
|
||||
<Text c="dimmed" size="xs" fw={600}>
|
||||
{displayDate}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={4} mt={4}>
|
||||
{/* TODO: Add medalists when data is available */}
|
||||
<Badge variant="dot" color="gold">
|
||||
Longer Team Name Goes Here
|
||||
</Badge>
|
||||
<Badge variant="dot" color="silver">
|
||||
Some Team
|
||||
</Badge>
|
||||
<Badge variant="dot" color="orange">
|
||||
Medium Team Name
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,106 +2,124 @@ 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";
|
||||
import { TournamentFormInput } from "@/features/tournaments/types";
|
||||
import { TournamentInput } from "@/features/tournaments/types";
|
||||
import { isNotEmpty } from "@mantine/form";
|
||||
import useCreateTournament from "../hooks/use-create-tournament";
|
||||
import useUpdateTournament from "../hooks/use-update-tournament";
|
||||
import toast from '@/lib/sonner';
|
||||
import toast from "@/lib/sonner";
|
||||
import { logger } from "..";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentQueries } from "@/features/tournaments/queries";
|
||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface TournamentFormProps {
|
||||
close: () => void;
|
||||
initialValues?: Partial<TournamentFormInput>;
|
||||
initialValues?: Partial<TournamentInput>;
|
||||
tournamentId?: string;
|
||||
}
|
||||
|
||||
const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormProps) => {
|
||||
const TournamentForm = ({
|
||||
close,
|
||||
initialValues,
|
||||
tournamentId,
|
||||
}: TournamentFormProps) => {
|
||||
const isEditMode = !!tournamentId;
|
||||
|
||||
const config: UseFormInput<TournamentFormInput> = {
|
||||
const config: UseFormInput<TournamentInput> = {
|
||||
initialValues: {
|
||||
name: initialValues?.name || '',
|
||||
location: initialValues?.location || '',
|
||||
desc: initialValues?.desc || '',
|
||||
start_time: initialValues?.start_time || '',
|
||||
enroll_time: initialValues?.enroll_time || '',
|
||||
end_time: initialValues?.end_time || '',
|
||||
name: initialValues?.name || "",
|
||||
location: initialValues?.location || "",
|
||||
desc: initialValues?.desc || "",
|
||||
start_time: initialValues?.start_time || "",
|
||||
enroll_time: initialValues?.enroll_time || "",
|
||||
end_time: initialValues?.end_time || "",
|
||||
logo: undefined,
|
||||
},
|
||||
onSubmitPreventDefault: 'always',
|
||||
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'),
|
||||
}
|
||||
}
|
||||
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 queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createTournament, isPending: createPending } = useCreateTournament();
|
||||
const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId || '');
|
||||
|
||||
const { mutate: createTournament, isPending: createPending } =
|
||||
useCreateTournament();
|
||||
const { mutate: updateTournament, isPending: updatePending } =
|
||||
useUpdateTournament(tournamentId || "");
|
||||
|
||||
const isPending = createPending || updatePending;
|
||||
|
||||
const handleSubmit = useCallback(async (values: TournamentFormInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode ? 'Tournament updated successfully!' : 'Tournament created successfully!';
|
||||
const errorMessage = isEditMode ? 'Failed to update tournament' : 'Failed to create tournament';
|
||||
|
||||
mutation(tournamentData, {
|
||||
onSuccess: async (tournament) => {
|
||||
if (logo && tournament) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('tournamentId', tournament.id);
|
||||
formData.append('logo', logo);
|
||||
const handleSubmit = useCallback(
|
||||
async (values: TournamentInput) => {
|
||||
const { logo, ...tournamentData } = values;
|
||||
|
||||
const response = await fetch('/api/tournaments/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const mutation = isEditMode ? updateTournament : createTournament;
|
||||
const successMessage = isEditMode
|
||||
? "Tournament updated successfully!"
|
||||
: "Tournament created successfully!";
|
||||
const errorMessage = isEditMode
|
||||
? "Failed to update tournament"
|
||||
: "Failed to create tournament";
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to upload logo');
|
||||
mutation(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: tournamentKeys.list });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(result.tournament!.id),
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
tournamentKeys.details(result.tournament!.id),
|
||||
result.tournament
|
||||
);
|
||||
|
||||
toast.success(successMessage);
|
||||
} catch (error: any) {
|
||||
const logoErrorMessage = isEditMode
|
||||
? `Tournament updated but logo upload failed: ${error.message}`
|
||||
: `Tournament created but logo upload failed: ${error.message}`;
|
||||
toast.error(logoErrorMessage);
|
||||
logger.error("Tournament logo upload error", error);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.list().queryKey });
|
||||
queryClient.invalidateQueries({ queryKey: tournamentQueries.details(result.tournament!.id).queryKey });
|
||||
queryClient.setQueryData(
|
||||
tournamentQueries.details(result.tournament!.id).queryKey,
|
||||
result.tournament
|
||||
);
|
||||
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
} catch (error: any) {
|
||||
const logoErrorMessage = isEditMode
|
||||
? `Tournament updated but logo upload failed: ${error.message}`
|
||||
: `Tournament created but logo upload failed: ${error.message}`;
|
||||
toast.error(logoErrorMessage);
|
||||
logger.error('Tournament logo upload error', error);
|
||||
}
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
close();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(`Tournament ${isEditMode ? 'update' : 'create'} error`, error);
|
||||
}
|
||||
});
|
||||
}, [isEditMode, createTournament, updateTournament, queryClient]);
|
||||
close();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`${errorMessage}: ${error.message}`);
|
||||
logger.error(
|
||||
`Tournament ${isEditMode ? "update" : "create"} error`,
|
||||
error
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
[isEditMode, createTournament, updateTournament, queryClient]
|
||||
);
|
||||
|
||||
return (
|
||||
<SlidePanel
|
||||
@@ -115,83 +133,91 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
|
||||
<TextInput
|
||||
label="Name"
|
||||
withAsterisk
|
||||
key={form.key('name')}
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key("name")}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Location"
|
||||
withAsterisk
|
||||
key={form.key('location')}
|
||||
{...form.getInputProps('location')}
|
||||
key={form.key("location")}
|
||||
{...form.getInputProps("location")}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
key={form.key('desc')}
|
||||
{...form.getInputProps('desc')}
|
||||
key={form.key("desc")}
|
||||
{...form.getInputProps("desc")}
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<FileInput
|
||||
key={form.key('logo')}
|
||||
key={form.key("logo")}
|
||||
accept="image/png,image/jpeg,image/gif,image/jpg"
|
||||
label={isEditMode ? "Change Logo" : "Logo"}
|
||||
leftSection={<LinkIcon size={16} />}
|
||||
{...form.getInputProps('logo')}
|
||||
{...form.getInputProps("logo")}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key('start_time')}
|
||||
{...form.getInputProps('start_time')}
|
||||
key={form.key("start_time")}
|
||||
{...form.getInputProps("start_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select Start Date"
|
||||
label="Start Date"
|
||||
withAsterisk
|
||||
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
})}
|
||||
formatValue={(date) =>
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SlidePanelField
|
||||
key={form.key('enroll_time')}
|
||||
{...form.getInputProps('enroll_time')}
|
||||
key={form.key("enroll_time")}
|
||||
{...form.getInputProps("enroll_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select Enrollment Due Date"
|
||||
label="Enrollment Due"
|
||||
withAsterisk
|
||||
formatValue={(date) => new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
})}
|
||||
formatValue={(date) =>
|
||||
new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{isEditMode && (
|
||||
<SlidePanelField
|
||||
key={form.key('end_time')}
|
||||
{...form.getInputProps('end_time')}
|
||||
key={form.key("end_time")}
|
||||
{...form.getInputProps("end_time")}
|
||||
Component={DateTimePicker}
|
||||
title="Select End Date"
|
||||
label="End Date (Optional)"
|
||||
formatValue={(date) => date ? new Date(date).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true
|
||||
}) : 'Not set'}
|
||||
formatValue={(date) =>
|
||||
date
|
||||
? new Date(date).toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
: "Not set"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -199,4 +225,4 @@ const TournamentForm = ({ close, initialValues, tournamentId }: TournamentFormPr
|
||||
);
|
||||
};
|
||||
|
||||
export default TournamentForm;
|
||||
export default TournamentForm;
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { createTournament } from "@/features/tournaments/server";
|
||||
import toast from '@/lib/sonner';
|
||||
import { TournamentInput } from "@/features/tournaments/types";
|
||||
import { logger } from "../";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useCreateTournament = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
return useServerMutation({
|
||||
mutationFn: (data: TournamentInput) => createTournament({ data }),
|
||||
onMutate: (data) => {
|
||||
logger.info('Creating tournament', data);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
toast.error('There was an issue creating your tournament. Please try again later.');
|
||||
logger.error('Error creating tournament', data);
|
||||
} else {
|
||||
logger.info('Tournament created successfully', data);
|
||||
navigate({ to: '/tournaments' });
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
logger.error('Error creating tournament', error);
|
||||
if (error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('An unexpected error occurred when trying to create a tournament. Please try again later.');
|
||||
}
|
||||
onSuccess: () => {
|
||||
navigate({ to: '/tournaments' });
|
||||
},
|
||||
successMessage: 'Tournament created successfully!',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { enrollTeam } from "@/features/tournaments/server";
|
||||
import toast from '@/lib/sonner';
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useEnrollTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useServerMutation({
|
||||
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
||||
return enrollTeam({ data });
|
||||
},
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
if (!data) {
|
||||
toast.error('There was an issue enrolling. Please try again later.');
|
||||
} else {
|
||||
// Invalidate both tournament details and unenrolled teams queries
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||
toast.success('Team enrolled successfully!');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('An unexpected error occurred when trying to enroll the team. Please try again later.');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||
},
|
||||
successMessage: 'Team enrolled successfully!',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { unenrollTeam } from "@/features/tournaments/server";
|
||||
import toast from '@/lib/sonner';
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useUnenrollTeam = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
return useServerMutation({
|
||||
mutationFn: (data: { tournamentId: string, teamId: string }) => {
|
||||
return unenrollTeam({ data });
|
||||
},
|
||||
onSuccess: (data, { tournamentId }) => {
|
||||
if (!data) {
|
||||
toast.error('There was an issue unenrolling. Please try again later.');
|
||||
} else {
|
||||
// Invalidate both tournament details and unenrolled teams queries
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||
toast.success('Team unenrolled successfully.');
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('An unexpected error occurred when trying to unenroll the team. Please try again later.');
|
||||
}
|
||||
onSuccess: (_, { tournamentId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'details', tournamentId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['tournaments', 'unenrolled', tournamentId] });
|
||||
},
|
||||
successMessage: 'Team unenrolled successfully.',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateTournament } from "@/features/tournaments/server";
|
||||
import { TournamentFormInput } from "@/features/tournaments/types";
|
||||
import { TournamentInput } from "@/features/tournaments/types";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const useUpdateTournament = (tournamentId: string) => {
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<TournamentFormInput>) =>
|
||||
return useServerMutation({
|
||||
mutationFn: (data: Partial<TournamentInput>) =>
|
||||
updateTournament({ data: { id: tournamentId, updates: data } }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||
import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
|
||||
const tournamentKeys = {
|
||||
export const tournamentKeys = {
|
||||
list: ['tournaments', 'list'] as const,
|
||||
details: (id: string) => ['tournaments', 'details', id] as const,
|
||||
unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
|
||||
};
|
||||
|
||||
export const tournamentQueries = {
|
||||
list: () => queryOptions({
|
||||
list: () => ({
|
||||
queryKey: tournamentKeys.list,
|
||||
queryFn: listTournaments
|
||||
}),
|
||||
details: (id: string) => queryOptions({
|
||||
details: (id: string) => ({
|
||||
queryKey: tournamentKeys.details(id),
|
||||
queryFn: () => getTournament({ data: id })
|
||||
}),
|
||||
unenrolled: (id: string) => queryOptions({
|
||||
unenrolled: (id: string) => ({
|
||||
queryKey: tournamentKeys.unenrolled(id),
|
||||
queryFn: () => getUnenrolledTeams({ data: id })
|
||||
})
|
||||
};
|
||||
|
||||
export const useTournaments = () =>
|
||||
useServerSuspenseQuery(tournamentQueries.list());
|
||||
|
||||
export const useTournament = (id: string) =>
|
||||
useServerSuspenseQuery(tournamentQueries.details(id));
|
||||
|
||||
export const useUnenrolledTeams = (tournamentId: string) =>
|
||||
useServerSuspenseQuery(tournamentQueries.unenrolled(tournamentId));
|
||||
|
||||
@@ -4,34 +4,20 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { tournamentInputSchema } from "@/features/tournaments/types";
|
||||
import { logger } from ".";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
export const listTournaments = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () => {
|
||||
try {
|
||||
const result = await pbAdmin.listTournaments();
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tournaments', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.listTournaments)
|
||||
);
|
||||
|
||||
export const createTournament = createServerFn()
|
||||
.validator(tournamentInputSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
logger.info('Creating tournament', data);
|
||||
|
||||
const tournament = await pbAdmin.createTournament(data);
|
||||
|
||||
return tournament;
|
||||
} catch (error) {
|
||||
logger.error('Error creating tournament', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(() => pbAdmin.createTournament(data))
|
||||
);
|
||||
|
||||
export const updateTournament = createServerFn()
|
||||
.validator(z.object({
|
||||
@@ -39,26 +25,16 @@ export const updateTournament = createServerFn()
|
||||
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;
|
||||
}
|
||||
});
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(() => pbAdmin.updateTournament(data.id, data.updates))
|
||||
);
|
||||
|
||||
export const getTournament = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) => {
|
||||
logger.info('Getting tournament', tournamentId);
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
return tournament;
|
||||
});
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
||||
);
|
||||
|
||||
export const enrollTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
@@ -66,8 +42,8 @@ export const enrollTeam = createServerFn()
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) => {
|
||||
try {
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
@@ -83,11 +59,8 @@ export const enrollTeam = createServerFn()
|
||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, userId });
|
||||
const tournament = await pbAdmin.enrollTeam(tournamentId, teamId);
|
||||
return tournament;
|
||||
} catch (error) {
|
||||
logger.error('Error enrolling team', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export const unenrollTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
@@ -95,22 +68,13 @@ export const unenrollTeam = createServerFn()
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) => {
|
||||
try {
|
||||
logger.info('Enrolling team in tournament', { tournamentId, teamId, context });
|
||||
const tournament = await pbAdmin.unenrollTeam(tournamentId, teamId);
|
||||
return tournament;
|
||||
} catch (error) {
|
||||
logger.error('Error enrolling team', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(() => pbAdmin.unenrollTeam(tournamentId, teamId))
|
||||
);
|
||||
|
||||
export const getUnenrolledTeams = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) => {
|
||||
logger.info('Getting unenrolled teams', tournamentId);
|
||||
const teams = await pbAdmin.getUnenrolledTeams(tournamentId);
|
||||
return teams;
|
||||
});
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
||||
);
|
||||
@@ -16,19 +16,6 @@ export interface Tournament {
|
||||
teams?: Team[];
|
||||
}
|
||||
|
||||
// Schema for the form (client-side)
|
||||
export const tournamentFormSchema = z.object({
|
||||
name: z.string(),
|
||||
location: z.string().optional(),
|
||||
desc: z.string().optional(),
|
||||
rules: z.string().optional(),
|
||||
logo: z.file().optional(),
|
||||
enroll_time: z.string(),
|
||||
start_time: z.string(),
|
||||
end_time: z.string().optional(),
|
||||
});
|
||||
|
||||
// Schema for the server input (with base64 logo)
|
||||
export const tournamentInputSchema = z.object({
|
||||
name: z.string(),
|
||||
location: z.string().optional(),
|
||||
@@ -40,6 +27,5 @@ export const tournamentInputSchema = z.object({
|
||||
end_time: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TournamentFormInput = z.infer<typeof tournamentFormSchema>;
|
||||
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
||||
export type TournamentUpdateInput = Partial<TournamentInput>;
|
||||
|
||||
Reference in New Issue
Block a user