significant refactor

This commit is contained in:
2025-08-30 01:42:23 -05:00
parent 7136f646a3
commit 052f53444e
106 changed files with 1994 additions and 1701 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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>
);
};
};

View File

@@ -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>

View File

@@ -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>
);
};
};

View File

@@ -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>
);
}
}

View File

@@ -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));

View File

@@ -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;
});
})
);

View File

@@ -1,4 +1,3 @@
export interface Slot {
seed?: number;
team?: any;

View File

@@ -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);
}
}

View File

@@ -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,
};

View File

@@ -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}

View File

@@ -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 (

View File

@@ -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']
}
]

View File

@@ -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>();

View File

@@ -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!');

View File

@@ -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: '/' });
}
});
};

View File

@@ -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>
}
];

View File

@@ -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: {

View File

@@ -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());

View File

@@ -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)
);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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))
);

View File

@@ -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>;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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>
)
}
);
};

View File

@@ -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;

View File

@@ -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!',
});
};

View File

@@ -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!',
});
};

View File

@@ -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.',
});
};

View File

@@ -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 } }),
});
};

View File

@@ -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));

View File

@@ -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))
);

View File

@@ -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>;