From a463bb734ffdd4e8409b67ae949afb3a9ccc8fad Mon Sep 17 00:00:00 2001 From: Mattrixwv Date: Thu, 6 Mar 2025 22:31:31 -0500 Subject: [PATCH] Game Classes tab working --- src/hooks/GameClassHooks.ts | 140 ++++++++++++++++++ src/interface/GameClass.ts | 6 + src/pages/protected/GamePage.tsx | 3 +- src/ui/gameClass/GameClassAdminButtons.tsx | 38 +++++ src/ui/gameClass/GameClassCreateAndSearch.tsx | 61 ++++++++ src/ui/gameClass/GameClassDisplay.tsx | 67 +++++++++ src/ui/gameClass/GameClassList.tsx | 103 +++++++++++++ src/ui/gameClass/GameClassListSkeleton.tsx | 7 + src/ui/gameClass/GameClassLoader.tsx | 34 +++++ .../gameClass/modals/DeleteGameClassModal.tsx | 69 +++++++++ src/ui/gameClass/modals/GameClassModal.tsx | 116 +++++++++++++++ .../raidGroup/modals/DeleteRaidGroupModal.tsx | 4 +- src/ui/raidGroup/modals/RaidGroupModal.tsx | 1 + 13 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 src/hooks/GameClassHooks.ts create mode 100644 src/interface/GameClass.ts create mode 100644 src/ui/gameClass/GameClassAdminButtons.tsx create mode 100644 src/ui/gameClass/GameClassCreateAndSearch.tsx create mode 100644 src/ui/gameClass/GameClassDisplay.tsx create mode 100644 src/ui/gameClass/GameClassList.tsx create mode 100644 src/ui/gameClass/GameClassListSkeleton.tsx create mode 100644 src/ui/gameClass/GameClassLoader.tsx create mode 100644 src/ui/gameClass/modals/DeleteGameClassModal.tsx create mode 100644 src/ui/gameClass/modals/GameClassModal.tsx diff --git a/src/hooks/GameClassHooks.ts b/src/hooks/GameClassHooks.ts new file mode 100644 index 0000000..705b846 --- /dev/null +++ b/src/hooks/GameClassHooks.ts @@ -0,0 +1,140 @@ +import { GameClass } from "@/interface/GameClass"; +import { api } from "@/util/AxiosUtil"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + + +export function useGetGameClasses(gameId: string, page: number, pageSize: number, searchTerm?: string){ + return useQuery({ + queryKey: ["gameClasses", gameId, { page, pageSize, searchTerm }], + queryFn: async () => { + const params = new URLSearchParams(); + params.append("page", page.toString()); + params.append("pageSize", pageSize.toString()); + if(searchTerm){ + params.append("searchTerm", searchTerm); + } + + const response = await api.get(`/gameClass/game/${gameId}?${params}`); + + if(response.status !== 200){ + throw new Error("Failed to get game classes"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data as GameClass[]; + } + }); +} + +export function useGetGameClassesCount(gameId: string, searchTerm?: string){ + const searchParams = new URLSearchParams(); + if(searchTerm){ + searchParams.append("searchTerm", searchTerm); + } + + + return useQuery({ + queryKey: ["gameClasses", gameId, "count", searchTerm], + queryFn: async () => { + const response = await api.get(`/gameClass/game/${gameId}/count?${searchParams}`); + + if(response.status !== 200){ + throw new Error("Failed to get game classes count"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data.count as number; + } + }); +} + + +export function useCreateGameClass(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["createGameClass"], + mutationFn: async ({gameId, gameClassName, iconFile}:{gameId: string; gameClassName: string; iconFile: File | null}) => { + const formData = new FormData(); + if(iconFile){ + formData.append("iconFile", iconFile); + } + formData.append("gameClassName", gameClassName); + formData.append("gameId", gameId); + + const response = await api.post( + `/gameClass/game/${gameId}`, + formData + ); + + if(response.status !== 200){ + throw new Error("Failed to create game class"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["gameClasses"] }); + } + }); +} + +export function useUpdateGameClass(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["updateGameClass"], + mutationFn: async ({gameClass, iconFile}:{gameClass: GameClass; iconFile: File | null}) => { + const formData = new FormData(); + if(iconFile){ + formData.append("iconFile", iconFile); + } + formData.append("gameClassName", gameClass.gameClassName); + formData.append("gameId", gameClass.gameId); + if(gameClass.gameClassIcon){ + formData.append("gameClassIcon", gameClass.gameClassIcon); + } + + const response = await api.put(`/gameClass/${gameClass.gameClassId}/game/${gameClass.gameId}`, formData); + + if(response.status !== 200){ + throw new Error("Failed to update game class"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["gameClasses"] }); + } + }); +} + +export function useDeleteGameClass(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["deleteGameClass"], + mutationFn: async (gameClass: GameClass) => { + const response = await api.delete(`/gameClass/${gameClass.gameClassId}/game/${gameClass.gameId}`); + + if(response.status !== 200){ + throw new Error("Failed to delete game class"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["gameClasses"] }); + } + }); +} diff --git a/src/interface/GameClass.ts b/src/interface/GameClass.ts new file mode 100644 index 0000000..44f0277 --- /dev/null +++ b/src/interface/GameClass.ts @@ -0,0 +1,6 @@ +export interface GameClass { + gameClassId?: string; + gameId: string; + gameClassName: string; + gameClassIcon?: string; +} diff --git a/src/pages/protected/GamePage.tsx b/src/pages/protected/GamePage.tsx index bfcb68d..b3dcb79 100644 --- a/src/pages/protected/GamePage.tsx +++ b/src/pages/protected/GamePage.tsx @@ -3,6 +3,7 @@ import { useGetGame } from "@/hooks/GameHooks"; import { Game } from "@/interface/Game"; import GameCalendarDisplay from "@/ui/calendar/GameCalendarDisplay"; import GameHeader from "@/ui/game/GameHeader"; +import GameClassDisplay from "@/ui/gameClass/GameClassDisplay"; import RaidGroupsByGameDisplay from "@/ui/raidGroup/RaidGroupsByGameDisplay"; import { useEffect, useState } from "react"; import { Navigate, useParams } from "react-router"; @@ -21,7 +22,7 @@ export default function GamePage(){ }, { tabHeader: "Classes", - tabContent:
Classes
+ tabContent: } ]; diff --git a/src/ui/gameClass/GameClassAdminButtons.tsx b/src/ui/gameClass/GameClassAdminButtons.tsx new file mode 100644 index 0000000..071c4d3 --- /dev/null +++ b/src/ui/gameClass/GameClassAdminButtons.tsx @@ -0,0 +1,38 @@ +import { ButtonProps } from "@/components/button/Button"; +import DangerButton from "@/components/button/DangerButton"; +import PrimaryButton from "@/components/button/PrimaryButton"; +import { BsPencilFill, BsTrash3 } from "react-icons/bs"; + + +export default function GameClassAdminButtons({ + buttonProps, + showEditGameClassModal, + showDeleteGameClassModal +}:{ + buttonProps: ButtonProps; + showEditGameClassModal: () => void; + showDeleteGameClassModal: () => void; +}){ + return ( +
+ + + + + + +
+ ); +} diff --git a/src/ui/gameClass/GameClassCreateAndSearch.tsx b/src/ui/gameClass/GameClassCreateAndSearch.tsx new file mode 100644 index 0000000..64a5ac7 --- /dev/null +++ b/src/ui/gameClass/GameClassCreateAndSearch.tsx @@ -0,0 +1,61 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import TextInput from "@/components/input/TextInput"; +import { useState } from "react"; +import GameClassModal from "./modals/GameClassModal"; + + +export default function GameClassCreateAndSearch({ + gameId, + searchTerm, + setSearchTerm +}:{ + gameId: string; + searchTerm: string; + setSearchTerm: (searchTerm: string) => void; +}){ + const [ displayGameClassModal, setDisplayGameClassModal ] = useState(false); + const modalId = crypto.randomUUID().replaceAll("-", ""); + + + return ( +
+
+   +
+ {/* Add Game Class Button */} +
+ setDisplayGameClassModal(true)} + > + Create Game Class + + setDisplayGameClassModal(false)} + gameId={gameId} + gameClass={undefined} + /> +
+ {/* Game Class Search Box */} +
+
+ setSearchTerm(e.target.value)} + placeholder="Search" + /> +
+
+
+ ); +} diff --git a/src/ui/gameClass/GameClassDisplay.tsx b/src/ui/gameClass/GameClassDisplay.tsx new file mode 100644 index 0000000..494d919 --- /dev/null +++ b/src/ui/gameClass/GameClassDisplay.tsx @@ -0,0 +1,67 @@ +import Pagination from "@/components/pagination/Pagination"; +import { useGetGameClassesCount } from "@/hooks/GameClassHooks"; +import { useEffect, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import GameClassCreateAndSearch from "./GameClassCreateAndSearch"; +import GameClassLoader from "./GameClassLoader"; + + +export default function GameClassDisplay({ + gameId +}:{ + gameId: string; +}){ + const [ page, setPage ] = useState(1); + const [ totalPages, setTotalPages ] = useState(1); + const [ searchTerm, setSearchTerm ] = useState(""); + const [ sentSearchTerm, setSentSearchTerm ] = useState(); + const pageSize = 10; + + + const gameClassCountQuery = useGetGameClassesCount(gameId, sentSearchTerm); + + + const updateSearchTerm = useDebouncedCallback((newSearchTerm: string) => { + setSentSearchTerm(newSearchTerm.length ? newSearchTerm : undefined); + }, 1000); + + useEffect(() => { + updateSearchTerm(searchTerm); + }, [ searchTerm, updateSearchTerm ]); + + useEffect(() => { + if(gameClassCountQuery.status === "success"){ + setTotalPages(Math.ceil(gameClassCountQuery.data / pageSize)); + } + }, [ gameClassCountQuery ]); + + + return ( +
+ + {/* Game Class List */} + + {/* Pagination */} +
+ +
+
+ ); +} diff --git a/src/ui/gameClass/GameClassList.tsx b/src/ui/gameClass/GameClassList.tsx new file mode 100644 index 0000000..5f26a07 --- /dev/null +++ b/src/ui/gameClass/GameClassList.tsx @@ -0,0 +1,103 @@ +import { ButtonProps } from "@/components/button/Button"; +import Table from "@/components/table/Table"; +import { GameClass } from "@/interface/GameClass"; +import { useState } from "react"; +import GameClassAdminButtons from "./GameClassAdminButtons"; +import DeleteGameClassModal from "./modals/DeleteGameClassModal"; +import GameClassModal from "./modals/GameClassModal"; + + +export default function GameClassList({ + gameClasses +}:{ + gameClasses: GameClass[]; +}){ + const [ selectedGameClass, setSelectedGameClass ] = useState(); + const [ displayGameClassModal, setDisplayGameClassModal ] = useState(false); + const [ displayDeleteGameClassModal, setDisplayDeleteGameClassModal ] = useState(false); + + + const buttonProps: ButtonProps = { + variant: "ghost", + size: "md", + shape: "square" + }; + + + const headElements: React.ReactNode[] = [ +
+ Icon +
, +
+ Name +
, +
+ Actions +
+ ]; + + const bodyElements: React.ReactNode[][] = gameClasses.map((gameClass) => [ +
+ { + gameClass.gameClassIcon && +
+ +
+ } +   +
, +
+ {gameClass.gameClassName} +
, +
+
+   +
+ { + setSelectedGameClass(gameClass); + setDisplayGameClassModal(true); + }} + showDeleteGameClassModal={() => { + setSelectedGameClass(gameClass); + setDisplayDeleteGameClassModal(true); + }} + /> +
+ ]); + + + return ( + <> + + {setDisplayGameClassModal(false); setSelectedGameClass(undefined);}} + gameId={selectedGameClass?.gameId ?? ""} + gameClass={selectedGameClass} + /> + {setDisplayDeleteGameClassModal(false); setSelectedGameClass(undefined);}} + gameClass={selectedGameClass} + /> + + ); +} diff --git a/src/ui/gameClass/GameClassListSkeleton.tsx b/src/ui/gameClass/GameClassListSkeleton.tsx new file mode 100644 index 0000000..fb85795 --- /dev/null +++ b/src/ui/gameClass/GameClassListSkeleton.tsx @@ -0,0 +1,7 @@ +export default function GameClassListSkeleton(){ + return ( +
+ Game Class List Skeleton +
+ ); +} diff --git a/src/ui/gameClass/GameClassLoader.tsx b/src/ui/gameClass/GameClassLoader.tsx new file mode 100644 index 0000000..8f790d0 --- /dev/null +++ b/src/ui/gameClass/GameClassLoader.tsx @@ -0,0 +1,34 @@ +import DangerMessage from "@/components/message/DangerMessage"; +import { useGetGameClasses } from "@/hooks/GameClassHooks"; +import GameClassList from "./GameClassList"; +import GameClassListSkeleton from "./GameClassListSkeleton"; + + +export default function GameClassLoader({ + gameId, + page, + pageSize, + searchTerm +}:{ + gameId: string; + page: number; + pageSize: number; + searchTerm?: string; +}){ + const gameClassQuery = useGetGameClasses(gameId, page - 1, pageSize, searchTerm); + + + if(gameClassQuery.status === "pending"){ + return + } + else if(gameClassQuery.status === "error"){ + return Error {gameClassQuery.error.message} + } + else{ + return ( + + ); + } +} diff --git a/src/ui/gameClass/modals/DeleteGameClassModal.tsx b/src/ui/gameClass/modals/DeleteGameClassModal.tsx new file mode 100644 index 0000000..ac44828 --- /dev/null +++ b/src/ui/gameClass/modals/DeleteGameClassModal.tsx @@ -0,0 +1,69 @@ +import DangerButton from "@/components/button/DangerButton"; +import SecondaryButton from "@/components/button/SecondaryButton"; +import RaidBuilderModal from "@/components/modal/RaidBuilderModal"; +import { useDeleteGameClass } from "@/hooks/GameClassHooks"; +import { GameClass } from "@/interface/GameClass"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect } from "react"; + + +export default function DeleteGameClassModal({ + display, + close, + gameClass +}:{ + display: boolean; + close: () => void; + gameClass: GameClass | undefined; +}){ + const deleteGameClassMutate = useDeleteGameClass(); + const { addSuccessMessage, addErrorMessage} = useTimedModal(); + + + const deleteGameClass = () => { + if(gameClass){ + deleteGameClassMutate.mutate(gameClass); + } + else{ + addErrorMessage("No game class selected"); + } + } + + + useEffect(() => { + if(deleteGameClassMutate.status === "success"){ + deleteGameClassMutate.reset(); + addSuccessMessage(`Deleted game class ${gameClass?.gameClassName}`); + close(); + } + else if(deleteGameClassMutate.status === "error"){ + deleteGameClassMutate.reset(); + addErrorMessage(`Error deleting game class ${gameClass?.gameClassName}: ${deleteGameClassMutate.error.message}`); + console.log(deleteGameClassMutate.error); + } + }); + + + return ( + + + Delete + + + Cancel + + + } + /> + ); +} diff --git a/src/ui/gameClass/modals/GameClassModal.tsx b/src/ui/gameClass/modals/GameClassModal.tsx new file mode 100644 index 0000000..8560212 --- /dev/null +++ b/src/ui/gameClass/modals/GameClassModal.tsx @@ -0,0 +1,116 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import SecondaryButton from "@/components/button/SecondaryButton"; +import IconInput from "@/components/input/IconInput"; +import TextInput from "@/components/input/TextInput"; +import RaidBuilderModal from "@/components/modal/RaidBuilderModal"; +import { useCreateGameClass, useUpdateGameClass } from "@/hooks/GameClassHooks"; +import { GameClass } from "@/interface/GameClass"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect, useState } from "react"; + + +export default function GameClassModal({ + display, + close, + gameId, + gameClass +}:{ + display: boolean; + close: () => void; + gameId: string; + gameClass?: GameClass | null; +}){ + const [ gameClassName, setGameClassName ] = useState(gameClass?.gameClassName ?? ""); + const [ gameClassIcon, setGameClassIcon ] = useState(gameClass?.gameClassIcon); + const [ iconFile, setIconFile ] = useState(null); + const modalId = crypto.randomUUID().replaceAll("-", ""); + + + useEffect(() => { + setGameClassName(gameClass?.gameClassName ?? ""); + setGameClassIcon(gameClass?.gameClassIcon); + setIconFile(null); + }, [ gameClass, setGameClassName, setGameClassIcon, setIconFile ]); + + + const updateGameClassMutate = useUpdateGameClass(); + const createGameClassMutate = useCreateGameClass(); + const { addSuccessMessage, addErrorMessage } = useTimedModal(); + + + useEffect(() => { + if(updateGameClassMutate.status === "success"){ + updateGameClassMutate.reset(); + addSuccessMessage(`Updated game class ${gameClassName}`); + close(); + } + else if(updateGameClassMutate.status === "error"){ + updateGameClassMutate.reset(); + addErrorMessage(`Error updating game class ${gameClassName}: ${updateGameClassMutate.error.message}`); + console.log(updateGameClassMutate.error); + } + else if(createGameClassMutate.status === "success"){ + createGameClassMutate.reset(); + addSuccessMessage(`Deleted game class ${gameClassName}`); + close(); + } + else if(createGameClassMutate.status === "error"){ + createGameClassMutate.reset(); + addErrorMessage(`Error deleting game class ${gameClassName}: ${createGameClassMutate.error.message}`); + console.log(createGameClassMutate.error); + } + }, [ updateGameClassMutate, createGameClassMutate, gameClassName, close, addSuccessMessage, addErrorMessage ]); + + + const updateGameClass = () => { + updateGameClassMutate.mutate({ gameClass: {gameClassId: gameClass?.gameClassId, gameId, gameClassName, gameClassIcon}, iconFile}); + } + + const createGameClass = () => { + createGameClassMutate.mutate({ gameId, gameClassName, iconFile}); + } + + + return ( + +
+ setGameClassName(e.target.value)} + /> +
+ {setIconFile(file); setGameClassIcon(undefined);}} + addErrorMessage={addErrorMessage} + /> + + } + modalFooter={ + <> + + {gameClass ? "Update" : "Create"} + + + Cancel + + + } + /> + ); +} diff --git a/src/ui/raidGroup/modals/DeleteRaidGroupModal.tsx b/src/ui/raidGroup/modals/DeleteRaidGroupModal.tsx index 1061210..7bec524 100644 --- a/src/ui/raidGroup/modals/DeleteRaidGroupModal.tsx +++ b/src/ui/raidGroup/modals/DeleteRaidGroupModal.tsx @@ -20,7 +20,7 @@ export default function DeleteRaidGroupModal({ const { addSuccessMessage, addErrorMessage } = useTimedModal(); - const deleteGame = () => { + const deleteRaidGroup = () => { deleteRaidGroupMutate.mutate(raidGroup?.raidGroupId ?? ""); } @@ -47,7 +47,7 @@ export default function DeleteRaidGroupModal({ modalFooter={ <> Delete diff --git a/src/ui/raidGroup/modals/RaidGroupModal.tsx b/src/ui/raidGroup/modals/RaidGroupModal.tsx index f343cb1..7c51fd2 100644 --- a/src/ui/raidGroup/modals/RaidGroupModal.tsx +++ b/src/ui/raidGroup/modals/RaidGroupModal.tsx @@ -31,6 +31,7 @@ export default function RaidGroupModal({ useEffect(() => { setRaidGroupName(raidGroup?.raidGroupName ?? ""); setRaidGroupIcon(raidGroup?.raidGroupIcon ?? ""); + setIconFile(null); }, [ raidGroup, setRaidGroupName, setRaidGroupIcon ]);