From 381ddc8f3400027b10aad86e6d3adc723351afbc Mon Sep 17 00:00:00 2001 From: yohlo Date: Thu, 28 Aug 2025 18:09:09 -0500 Subject: [PATCH] several --- package.json | 1 + src/app/routes/_authed/admin/preview.tsx | 7 + .../routes/_authed/admin/tournaments/$id.tsx | 38 +----- src/components/sheet/drawer.tsx | 4 +- src/features/admin/components/admin-page.tsx | 3 +- src/features/bracket/components/preview.tsx | 23 ++-- src/features/bracket/server.ts | 3 +- src/features/bracket/types.ts | 10 +- src/features/core/hooks/use-links.ts | 3 +- .../players/components/profile/name-form.tsx | 93 +++++++------ .../components/edit-enrolled-teams.tsx | 123 ++++++++++++++++++ .../components/manage-tournament.tsx | 59 +++++++++ .../tournaments/hooks/use-enroll-team.ts | 4 +- .../tournaments/hooks/use-unenroll-team.ts | 4 +- src/features/tournaments/queries.ts | 13 +- src/features/tournaments/server.ts | 9 ++ src/lib/pocketbase/services/tournaments.ts | 44 +++++++ 17 files changed, 343 insertions(+), 98 deletions(-) create mode 100644 src/features/tournaments/components/edit-enrolled-teams.tsx create mode 100644 src/features/tournaments/components/manage-tournament.tsx 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; + } + }, }; }