diff --git a/package.json b/package.json
index 629f331..17d7828 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4",
"@mantine/hooks": "^8.2.4",
+ "@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
diff --git a/src/app/routes/_authed/admin/preview.tsx b/src/app/routes/_authed/admin/preview.tsx
index b2c40d5..95353f7 100644
--- a/src/app/routes/_authed/admin/preview.tsx
+++ b/src/app/routes/_authed/admin/preview.tsx
@@ -3,6 +3,13 @@ import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_authed/admin/preview')({
component: RouteComponent,
+ loader: () => ({
+ header: {
+ withBackButton: true,
+ title: "Bracket Preview"
+ },
+ withPadding: false
+ })
})
function RouteComponent() {
diff --git a/src/app/routes/_authed/admin/tournaments/$id.tsx b/src/app/routes/_authed/admin/tournaments/$id.tsx
index be017f8..f1b67c4 100644
--- a/src/app/routes/_authed/admin/tournaments/$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id.tsx
@@ -1,12 +1,6 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'
-import { useQuery } from '@tanstack/react-query'
-import { List } from '@mantine/core'
-import ListButton from '@/components/list-button'
-import { HardDrivesIcon, PencilLineIcon, UsersThreeIcon } from '@phosphor-icons/react'
-import { useSheet } from '@/hooks/use-sheet'
-import Sheet from '@/components/sheet/sheet'
-import TournamentForm from '@/features/tournaments/components/tournament-form'
+import ManageTournament from '@/features/tournaments/components/manage-tournament'
export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
beforeLoad: async ({ context, params }) => {
@@ -29,33 +23,5 @@ export const Route = createFileRoute('/_authed/admin/tournaments/$id')({
function RouteComponent() {
const { id } = Route.useParams()
- const { data: tournament } = useQuery(tournamentQueries.details(id))
- if (!tournament) throw new Error("Tournament not found.")
-
- const { isOpen: editTournamentOpened, open: openEditTournament, close: closeEditTournament } = useSheet();
-
- return (
- <>
-
-
-
-
-
-
-
-
-
- >
- )
+ return
}
\ No newline at end of file
diff --git a/src/components/sheet/drawer.tsx b/src/components/sheet/drawer.tsx
index cd3830d..c21d90d 100644
--- a/src/components/sheet/drawer.tsx
+++ b/src/components/sheet/drawer.tsx
@@ -1,4 +1,4 @@
-import { Box, Container } from "@mantine/core";
+import { Box, Container, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, useEffect } from "react";
import { Drawer as VaulDrawer } from 'vaul';
import { useMantineColorScheme } from '@mantine/core';
@@ -11,7 +11,7 @@ interface DrawerProps extends PropsWithChildren {
}
const Drawer: React.FC = ({ title, children, opened, onChange }) => {
- const { colorScheme } = useMantineColorScheme();
+ const colorScheme = useComputedColorScheme('light');
useEffect(() => {
const appElement = document.querySelector('.app') as HTMLElement;
diff --git a/src/features/admin/components/admin-page.tsx b/src/features/admin/components/admin-page.tsx
index a57fca4..e91c4b3 100644
--- a/src/features/admin/components/admin-page.tsx
+++ b/src/features/admin/components/admin-page.tsx
@@ -1,7 +1,7 @@
import { Title, List, Divider } from "@mantine/core";
import ListLink from "@/components/list-link";
import Page from "@/components/page";
-import { DatabaseIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
+import { DatabaseIcon, TreeStructureIcon, TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
const AdminPage = () => {
@@ -12,6 +12,7 @@ const AdminPage = () => {
window.location.replace(import.meta.env.VITE_POCKETBASE_URL! + "/_/")} />
+
);
diff --git a/src/features/bracket/components/preview.tsx b/src/features/bracket/components/preview.tsx
index 7674a29..5145d8f 100644
--- a/src/features/bracket/components/preview.tsx
+++ b/src/features/bracket/components/preview.tsx
@@ -10,17 +10,22 @@ import { useEffect, useState } from "react";
import { bracketQueries } from "../queries";
import { useQuery } from "@tanstack/react-query";
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
-import { BracketData, Match, Team } from "../types";
+import { BracketData, Match } from "../types";
import Bracket from "./bracket";
import "./styles.module.css";
+interface PreviewTeam {
+ id: string;
+ name: string;
+}
+
export const PreviewBracket: React.FC = () => {
const [teamCount, setTeamCount] = useState(20);
- const { data, isLoading, error } = useQuery(
+ const { data, isLoading, error } = useQuery(
bracketQueries.preview(teamCount)
);
- const [teams, setTeams] = useState([]);
+ const [teams, setTeams] = useState([]);
useEffect(() => {
setTeams(
@@ -40,7 +45,7 @@ export const PreviewBracket: React.FC = () => {
useEffect(() => {
if (!data || teams.length === 0) return;
- const maps = createBracketMaps(data);
+ const maps = createBracketMaps(data as BracketData);
setBracketMaps(maps);
const mapBracket = (bracket: Match[][]) => {
@@ -73,8 +78,9 @@ export const PreviewBracket: React.FC = () => {
);
};
- setSeededWinnersBracket(mapBracket(data.winners));
- setSeededLosersBracket(mapBracket(data.losers));
+ const bracketData = data as BracketData;
+ setSeededWinnersBracket(mapBracket(bracketData.winners));
+ setSeededLosersBracket(mapBracket(bracketData.losers));
}, [teams, data]);
if (error) return Error loading bracket
;
@@ -82,10 +88,7 @@ export const PreviewBracket: React.FC = () => {
return (
-
- Preview Bracket (Double Elimination)
-
-
+
Teams:
diff --git a/src/features/bracket/server.ts b/src/features/bracket/server.ts
index 8848c9c..c831db7 100644
--- a/src/features/bracket/server.ts
+++ b/src/features/bracket/server.ts
@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { Logger } from "@/lib/logger";
import brackets from './utils';
+import { BracketData } from "./types";
const logger = new Logger("Bracket Generation")
@@ -13,5 +14,5 @@ export const previewBracket = createServerFn()
logger.info('Generating bracket', teams);
if (!Object.keys(brackets).includes(teams.toString()))
throw Error("Bracket not available")
- return brackets[teams];
+ return brackets[teams as keyof typeof brackets] as BracketData;
});
\ No newline at end of file
diff --git a/src/features/bracket/types.ts b/src/features/bracket/types.ts
index ee64955..d9d2224 100644
--- a/src/features/bracket/types.ts
+++ b/src/features/bracket/types.ts
@@ -1,12 +1,16 @@
-import { Team } from "../teams/types";
+
+export interface Slot {
+ seed?: number;
+ team?: any;
+}
export interface Match {
lid: number;
round: number;
order: number | null;
type: string;
- home: Team;
- away?: Team;
+ home: Slot;
+ away: Slot;
reset?: boolean;
}
diff --git a/src/features/core/hooks/use-links.ts b/src/features/core/hooks/use-links.ts
index 3b160d8..9954aa0 100644
--- a/src/features/core/hooks/use-links.ts
+++ b/src/features/core/hooks/use-links.ts
@@ -1,8 +1,9 @@
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
-export const useLinks = (userId: number, roles: string[]) =>
+export const useLinks = (userId: string | undefined, roles: string[]) =>
useMemo(() => {
+ if (!userId) throw new Error("userId is undefined")
const links = [
{
label: 'Home',
diff --git a/src/features/players/components/profile/name-form.tsx b/src/features/players/components/profile/name-form.tsx
index fec34c5..9f58f39 100644
--- a/src/features/players/components/profile/name-form.tsx
+++ b/src/features/players/components/profile/name-form.tsx
@@ -1,6 +1,6 @@
import { updatePlayer } from "@/features/players/server";
import { useMutation } from "@tanstack/react-query";
-import { Stack, TextInput } from "@mantine/core"
+import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import toast from "@/lib/sonner";
import { useRouter } from "@tanstack/react-router";
@@ -8,54 +8,71 @@ import { Player } from "../../types";
import Button from "@/components/button";
interface NameUpdateFormProps {
- player: Player;
- toggle: () => void;
+ player: Player;
+ toggle: () => void;
}
const NameUpdateForm = ({ player, toggle }: NameUpdateFormProps) => {
- const router = useRouter();
+ const router = useRouter();
- const form = useForm({
+ const form = useForm({
initialValues: {
first_name: player.first_name,
- last_name: player.last_name
+ last_name: player.last_name,
},
validate: {
- first_name: (value: string) => {
- if (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'
+ 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";
+ },
+ 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";
},
- last_name: (value: string) => {
- if (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'
- }
- }
- })
-
- const { mutate: updateName, isPending } = useMutation({
- 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.');
- }
});
- const handleSubmit = async (data: { first_name: string, last_name: string }) => await updateName(data)
- return (
-
- )
-}
+ const { mutate: updateName, isPending } = useMutation({
+ 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."
+ );
+ },
+ });
+
+ const handleSubmit = async (data: {
+ first_name: string | undefined;
+ last_name: string | undefined;
+ }) => {
+ if (!data.first_name || !data.last_name) return;
+ await updateName({
+ first_name: data.first_name,
+ last_name: data.last_name,
+ });
+ };
+ return (
+
+ );
+};
export default NameUpdateForm;
diff --git a/src/features/tournaments/components/edit-enrolled-teams.tsx b/src/features/tournaments/components/edit-enrolled-teams.tsx
new file mode 100644
index 0000000..3564274
--- /dev/null
+++ b/src/features/tournaments/components/edit-enrolled-teams.tsx
@@ -0,0 +1,123 @@
+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 useEnrollTeam from "../hooks/use-enroll-team";
+import useUnenrollTeam from "../hooks/use-unenroll-team";
+import Avatar from "@/components/avatar";
+import { Team } from "@/features/teams/types";
+
+interface EditEnrolledTeamsProps {
+ tournamentId: string;
+}
+
+const TeamItem = memo(({ team, onUnenroll, disabled }: {
+ team: Team;
+ onUnenroll: (teamId: string) => void;
+ disabled: boolean;
+}) => {
+ const playerNames = useMemo(() =>
+ team.players?.map(p => `${p.first_name} ${p.last_name}`).join(", ") || "",
+ [team.players]
+ );
+
+ return (
+
+
+
+ {team.name}
+ {playerNames && (
+ {playerNames}
+ )}
+
+ onUnenroll(team.id)}
+ disabled={disabled}
+ size="sm"
+ >
+
+
+
+ );
+});
+
+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 { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
+ const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
+
+ 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 handleUnenrollTeam = useCallback((teamId: string) => {
+ unenrollTeam({ tournamentId, teamId });
+ }, [unenrollTeam, tournamentId]);
+
+ const isLoading = tournamentLoading || unenrolledLoading;
+ const enrolledTeams = tournament?.teams || [];
+ const hasEnrolledTeams = enrolledTeams.length > 0;
+
+ return (
+
+
+ Add Team
+ : null}
+ maxDropdownHeight={200}
+ limit={10}
+ />
+
+
+
+
+ Enrolled Teams
+ {enrolledTeams.length} teams
+
+
+ {isLoading ? (
+
+ ) : !hasEnrolledTeams ? (
+ No teams enrolled yet
+ ) : (
+
+ {enrolledTeams.map((team: Team) => (
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default EditEnrolledTeams;
diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx
new file mode 100644
index 0000000..814174c
--- /dev/null
+++ b/src/features/tournaments/components/manage-tournament.tsx
@@ -0,0 +1,59 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { tournamentQueries } 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 { useSheet } from "@/hooks/use-sheet";
+import EditEnrolledTeams from "./edit-enrolled-teams";
+
+interface ManageTournamentProps {
+ tournamentId: string;
+}
+
+const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
+ const { data: tournament } = useSuspenseQuery(
+ tournamentQueries.details(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();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+ >
+ )
+};
+
+export default ManageTournament;
diff --git a/src/features/tournaments/hooks/use-enroll-team.ts b/src/features/tournaments/hooks/use-enroll-team.ts
index 28138d3..f097e17 100644
--- a/src/features/tournaments/hooks/use-enroll-team.ts
+++ b/src/features/tournaments/hooks/use-enroll-team.ts
@@ -13,7 +13,9 @@ const useEnrollTeam = () => {
if (!data) {
toast.error('There was an issue enrolling. Please try again later.');
} else {
- queryClient.invalidateQueries({ queryKey: ['tournaments', 'detail', tournamentId] });
+ // 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!');
}
},
diff --git a/src/features/tournaments/hooks/use-unenroll-team.ts b/src/features/tournaments/hooks/use-unenroll-team.ts
index 073ea1f..58bedc6 100644
--- a/src/features/tournaments/hooks/use-unenroll-team.ts
+++ b/src/features/tournaments/hooks/use-unenroll-team.ts
@@ -13,7 +13,9 @@ const useUnenrollTeam = () => {
if (!data) {
toast.error('There was an issue unenrolling. Please try again later.');
} else {
- queryClient.invalidateQueries({ queryKey: ['tournaments', 'detail', tournamentId] });
+ // 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.');
}
},
diff --git a/src/features/tournaments/queries.ts b/src/features/tournaments/queries.ts
index 2a5ada5..64b283e 100644
--- a/src/features/tournaments/queries.ts
+++ b/src/features/tournaments/queries.ts
@@ -1,18 +1,23 @@
import { queryOptions, useQuery } from "@tanstack/react-query";
-import { getTournament, listTournaments } from "./server";
+import { getTournament, getUnenrolledTeams, listTournaments } from "./server";
const tournamentKeys = {
list: ['tournaments', 'list'] as const,
- details: (id: string) => ['tournaments', 'details', id] as const
+ details: (id: string) => ['tournaments', 'details', id] as const,
+ unenrolled: (id: string) => ['tournaments', 'unenrolled', id] as const
};
export const tournamentQueries = {
list: () => queryOptions({
queryKey: tournamentKeys.list,
- queryFn: listTournaments,
+ queryFn: listTournaments
}),
details: (id: string) => queryOptions({
queryKey: tournamentKeys.details(id),
- queryFn: () => getTournament({ data: id }),
+ queryFn: () => getTournament({ data: id })
}),
+ unenrolled: (id: string) => queryOptions({
+ queryKey: tournamentKeys.unenrolled(id),
+ queryFn: () => getUnenrolledTeams({ data: id })
+ })
};
diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts
index 84d10e0..87c0b32 100644
--- a/src/features/tournaments/server.ts
+++ b/src/features/tournaments/server.ts
@@ -105,3 +105,12 @@ export const unenrollTeam = createServerFn()
throw error;
}
});
+
+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;
+ });
\ No newline at end of file
diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts
index edc729b..c2c2446 100644
--- a/src/lib/pocketbase/services/tournaments.ts
+++ b/src/lib/pocketbase/services/tournaments.ts
@@ -4,8 +4,10 @@ import type {
TournamentInput,
TournamentUpdateInput,
} from "@/features/tournaments/types";
+import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase";
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
+import { transformTeam } from "@/lib/pocketbase/util/transform-types";
export function createTournamentsService(pb: PocketBase) {
return {
@@ -54,6 +56,12 @@ export function createTournamentsService(pb: PocketBase) {
{ "teams+": teamId },
{ expand: "teams, teams.players" }
);
+
+ await pb.collection("teams").update(
+ teamId,
+ { "tournaments+": tournamentId }
+ );
+
return transformTournament(result);
},
async unenrollTeam(
@@ -65,7 +73,43 @@ export function createTournamentsService(pb: PocketBase) {
{ "teams-": teamId },
{ expand: "teams, teams.players" }
);
+
+ await pb.collection("teams").update(
+ teamId,
+ { "tournaments-": tournamentId }
+ );
+
return transformTournament(result);
},
+ async getUnenrolledTeams(tournamentId: string): Promise {
+ try {
+ logger.info("PocketBase | Getting unenrolled teams for tournament", tournamentId);
+ const tournament = await pb.collection("tournaments").getOne(tournamentId, {
+ fields: "teams"
+ });
+
+ const enrolledTeamIds = tournament.teams || [];
+ if (enrolledTeamIds.length === 0) {
+ const allTeams = await pb.collection("teams").getFullList({
+ expand: "players"
+ });
+ return allTeams.map(transformTeam);
+ }
+
+ const filter = enrolledTeamIds
+ .map((teamId: string) => `id != "${teamId}"`)
+ .join(" && ");
+
+ const availableTeams = await pb.collection("teams").getFullList({
+ filter,
+ expand: "players"
+ });
+
+ return availableTeams.map(transformTeam);
+ } catch (error) {
+ logger.error("PocketBase | Error getting unenrolled teams", error);
+ throw error;
+ }
+ },
};
}