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