From ffe51d6fbb7a2e48edc99cc3cb5276a290e7f39d Mon Sep 17 00:00:00 2001 From: Mattrixwv Date: Tue, 4 Mar 2025 21:14:24 -0500 Subject: [PATCH] Games tab on admin page working --- src/components/input/FileInput.tsx | 48 +++++++ src/components/input/IconInput.tsx | 37 +++++ src/hooks/GameHooks.ts | 131 ++++++++++++++++++ src/interface/Game.ts | 5 + src/pages/protected/AdminPage.tsx | 5 + src/ui/account/AccountsLoader.tsx | 59 ++++++-- src/ui/account/modals/AccountModal.tsx | 8 +- .../modals/AccountPasswordResetModal.tsx | 4 +- src/ui/account/modals/DeleteAccountModal.tsx | 4 +- .../modals/ForcePasswordResetModal.tsx | 4 +- .../modals/RevokeRefreshTokenModal.tsx | 4 +- src/ui/game/GameAdminButtons.tsx | 38 +++++ src/ui/game/GamesList.tsx | 102 ++++++++++++++ src/ui/game/GamesListSkeleton.tsx | 8 ++ src/ui/game/GamesLoader.tsx | 95 +++++++++++++ src/ui/game/modals/DeleteGameModal.tsx | 62 +++++++++ src/ui/game/modals/GameModal.tsx | 113 +++++++++++++++ 17 files changed, 700 insertions(+), 27 deletions(-) create mode 100644 src/components/input/FileInput.tsx create mode 100644 src/components/input/IconInput.tsx create mode 100644 src/hooks/GameHooks.ts create mode 100644 src/interface/Game.ts create mode 100644 src/ui/game/GameAdminButtons.tsx create mode 100644 src/ui/game/GamesList.tsx create mode 100644 src/ui/game/GamesListSkeleton.tsx create mode 100644 src/ui/game/GamesLoader.tsx create mode 100644 src/ui/game/modals/DeleteGameModal.tsx create mode 100644 src/ui/game/modals/GameModal.tsx diff --git a/src/components/input/FileInput.tsx b/src/components/input/FileInput.tsx new file mode 100644 index 0000000..05e6c98 --- /dev/null +++ b/src/components/input/FileInput.tsx @@ -0,0 +1,48 @@ +import { BsCloudUpload } from "react-icons/bs"; + +export default function FileInput({ + file, + setFile +}:{ + file: File | null | undefined; + setFile: (input: File | null) => void; +}){ + return ( +
+
+ Icon File +
+ setFile(e.target.files?.[0] ?? null)} + /> +
+
+ + Drop files anywhere or click to select file +
+ { + file && ( +

+ Name: {file.name} +

+ ) + } +
+
+ ); +} diff --git a/src/components/input/IconInput.tsx b/src/components/input/IconInput.tsx new file mode 100644 index 0000000..9b5938d --- /dev/null +++ b/src/components/input/IconInput.tsx @@ -0,0 +1,37 @@ +import FileInput from "./FileInput"; + + +export default function IconInput({ + file, + setFile, + addErrorMessage +}:{ + file: File | null | undefined; + setFile: (input: File | null) => void; + addErrorMessage: (message: string) => void; +}){ + const setIconFile = (inputFile: File | null) => { + if((inputFile) && (!inputFile.type.startsWith("image"))){ + addErrorMessage("File is invalid image format: " + inputFile.type); + } + //Prevent files larger than 10MB form being uploaded + else if((inputFile) && (inputFile.size > 10485760)){ + addErrorMessage("File is too large: " + inputFile.size + " bytes"); + } + //Prevent empty files + else if((inputFile) && (inputFile.size <= 0)){ + addErrorMessage("File is empty"); + } + else{ + setFile(inputFile); + } + } + + + return ( + + ); +} diff --git a/src/hooks/GameHooks.ts b/src/hooks/GameHooks.ts new file mode 100644 index 0000000..e580217 --- /dev/null +++ b/src/hooks/GameHooks.ts @@ -0,0 +1,131 @@ +import { Game } from "@/interface/Game"; +import { api } from "@/util/AxiosUtil"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + + +export function useGetGames(page: number, pageSize: number, searchTerm?: string){ + return useQuery({ + queryKey: ["games", { page, pageSize, searchTerm }], + queryFn: async () => { + const params = new URLSearchParams(); + params.append("page", page.toString()); + params.append("pageSize", pageSize.toString()); + if(searchTerm){ + params.append("search", searchTerm); + } + + const response = await api.get(`/game?${params}`); + + if(response.status !== 200){ + throw new Error("Failed to get games"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data as Game[]; + } + }); +} + +export function useGetGamesCount(){ + return useQuery({ + queryKey: ["games", "count"], + queryFn: async () => { + const response = await api.get("/game/count"); + + if(response.status !== 200){ + throw new Error("Failed to get games count"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + + return response.data.count as number; + } + }); +} + +export function useCreateGame(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["createGame"], + mutationFn: async ({gameName, iconFile}:{gameName: string, iconFile: File | null}) => { + const formData = new FormData(); + if(iconFile){ + formData.append("iconFile", iconFile); + } + formData.append("gameName", gameName); + + const response = await api.post( + "/game", + formData + ); + + if(response.status !== 200){ + throw new Error("Failed to create game"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["games"] }); + } + }); +} + +export function useUpdateGame(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["updateGame"], + mutationFn: async ({game, iconFile}:{game: Game, iconFile: File | null}) => { + const formData = new FormData(); + if(iconFile){ + formData.append("iconFile", iconFile); + } + formData.append("gameName", game.gameName); + if(game.gameIcon){ + formData.append("gameIcon", game.gameIcon); + } + + const response = await api.put(`/game/${game.gameId}`, formData); + + if(response.status !== 200){ + throw new Error("Failed to update game"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["games"] }); + } + }); +} + +export function useDeleteGame(){ + const queryClient = useQueryClient(); + + + return useMutation({ + mutationKey: ["deleteGame"], + mutationFn: async (gameId: string) => { + const response = await api.delete(`/game/${gameId}`); + + if(response.status !== 200){ + throw new Error("Failed to delete game"); + } + else if(response.data.errors){ + throw new Error(response.data.errors.join(", ")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["games"] }); + } + }); +} diff --git a/src/interface/Game.ts b/src/interface/Game.ts new file mode 100644 index 0000000..846f9ce --- /dev/null +++ b/src/interface/Game.ts @@ -0,0 +1,5 @@ +export interface Game{ + gameId?: string; + gameName: string; + gameIcon?: string; +} diff --git a/src/pages/protected/AdminPage.tsx b/src/pages/protected/AdminPage.tsx index c9b70cc..200c3d2 100644 --- a/src/pages/protected/AdminPage.tsx +++ b/src/pages/protected/AdminPage.tsx @@ -1,5 +1,6 @@ import TabGroup, { Tab } from "@/components/tab/TabGroup"; import AccountsLoader from "@/ui/account/AccountsLoader"; +import GamesLoader from "@/ui/game/GamesLoader"; export default function AdminPage(){ @@ -7,6 +8,10 @@ export default function AdminPage(){ { tabHeader: "Accounts", tabContent: + }, + { + tabHeader: "Games", + tabContent: } ]; diff --git a/src/ui/account/AccountsLoader.tsx b/src/ui/account/AccountsLoader.tsx index b2250f0..a61f2f9 100644 --- a/src/ui/account/AccountsLoader.tsx +++ b/src/ui/account/AccountsLoader.tsx @@ -1,4 +1,5 @@ import PrimaryButton from "@/components/button/PrimaryButton"; +import TextInput from "@/components/input/TextInput"; import DangerMessage from "@/components/message/DangerMessage"; import Pagination from "@/components/pagination/Pagination"; import { useGetAccounts, useGetAccountsCount } from "@/hooks/AccountHooks"; @@ -12,41 +13,69 @@ export default function AccountsLoader(){ const [ displayCreateAccountModal, setDisplayCreateAccountModal ] = useState(false); const [ page, setPage ] = useState(1); const [ totalPages, setTotalPages ] = useState(1); + const [ searchTerm, setSearchTerm ] = useState(""); const pageSize = 10; + const modalId = crypto.randomUUID().replace("-", ""); const accountsQuery = useGetAccounts(page - 1, pageSize); const accountsCountQuery = useGetAccountsCount(); useEffect(() => { - if(accountsCountQuery.isSuccess){ + if(accountsCountQuery.status === "success"){ setTotalPages(Math.ceil(accountsCountQuery.data / pageSize)); } }, [ accountsCountQuery ]); - if(accountsQuery.isLoading){ + if(accountsQuery.status === "pending"){ return } - else if(accountsQuery.isError){ + else if(accountsQuery.status === "error"){ return Error: {accountsQuery.error.message} } else{ return ( <> - {/* Add Account Button */} - setDisplayCreateAccountModal(true)} +
- Create Account - - setDisplayCreateAccountModal(false)} - account={undefined} - /> - {/* Account Search Bar */} +
+   +
+ {/* Add Account Button */} +
+ setDisplayCreateAccountModal(true)} + > + Create Account + + setDisplayCreateAccountModal(false)} + account={undefined} + /> +
+ {/* Account Search Box */} +
+
+ setSearchTerm(e.target.value)} + placeholder="Search" + /> +
+
+
+ {/* Account List */} diff --git a/src/ui/account/modals/AccountModal.tsx b/src/ui/account/modals/AccountModal.tsx index 9d950ad..bb6c9d9 100644 --- a/src/ui/account/modals/AccountModal.tsx +++ b/src/ui/account/modals/AccountModal.tsx @@ -40,22 +40,22 @@ export default function AccountModal({ useEffect(() => { - if(createAccountMutate.isSuccess){ + if(createAccountMutate.status === "success"){ createAccountMutate.reset(); addSuccessMessage(`Account ${username} created successfully`); close(); } - else if(updateAccountMutate.isSuccess){ + else if(updateAccountMutate.status === "success"){ updateAccountMutate.reset(); addSuccessMessage(`Account ${username} updated successfully`); close(); } - else if(createAccountMutate.isError){ + else if(createAccountMutate.status === "error"){ createAccountMutate.reset(); addErrorMessage(`Error creating account ${username}: ${createAccountMutate.error.message}`); console.log(createAccountMutate.error); } - else if(updateAccountMutate.isError){ + else if(updateAccountMutate.status === "error"){ updateAccountMutate.reset(); addErrorMessage(`Error updating account ${username}: ${updateAccountMutate.error.message}`); console.log(updateAccountMutate.error); diff --git a/src/ui/account/modals/AccountPasswordResetModal.tsx b/src/ui/account/modals/AccountPasswordResetModal.tsx index d757f5c..e7c5abd 100644 --- a/src/ui/account/modals/AccountPasswordResetModal.tsx +++ b/src/ui/account/modals/AccountPasswordResetModal.tsx @@ -30,12 +30,12 @@ export default function AccountPasswordRestModal({ } useEffect(() => { - if(passwordResetMutate.isSuccess){ + if(passwordResetMutate.status === "success"){ passwordResetMutate.reset(); addSuccessMessage(`Successfully reset password for ${account?.username}`); close(); } - else if(passwordResetMutate.isError){ + else if(passwordResetMutate.status === "error"){ passwordResetMutate.reset(); addErrorMessage(`Failed to reset password for ${account?.username}: ${passwordResetMutate.error.message}`); console.log(passwordResetMutate.error); diff --git a/src/ui/account/modals/DeleteAccountModal.tsx b/src/ui/account/modals/DeleteAccountModal.tsx index 8469600..e0bedbf 100644 --- a/src/ui/account/modals/DeleteAccountModal.tsx +++ b/src/ui/account/modals/DeleteAccountModal.tsx @@ -25,12 +25,12 @@ export default function DeleteAccountModal({ } useEffect(() => { - if(deleteAccountMutate.isSuccess){ + if(deleteAccountMutate.status === "success"){ deleteAccountMutate.reset(); addSuccessMessage(`Successfully deleted ${account?.username}`); close(); } - else if(deleteAccountMutate.isError){ + else if(deleteAccountMutate.status === "error"){ deleteAccountMutate.reset(); addErrorMessage(`Error deleting ${account?.username}: ${deleteAccountMutate.error.message}`); console.log(deleteAccountMutate.error); diff --git a/src/ui/account/modals/ForcePasswordResetModal.tsx b/src/ui/account/modals/ForcePasswordResetModal.tsx index c1da695..807a1e8 100644 --- a/src/ui/account/modals/ForcePasswordResetModal.tsx +++ b/src/ui/account/modals/ForcePasswordResetModal.tsx @@ -25,12 +25,12 @@ export default function ForcePasswordResetModal({ } useEffect(() => { - if(forcePasswordResetMutate.isSuccess){ + if(forcePasswordResetMutate.status === "success"){ forcePasswordResetMutate.reset(); addSuccessMessage(`Successfully forced password reset for ${account?.username}`); close(); } - else if(forcePasswordResetMutate.isError){ + else if(forcePasswordResetMutate.status === "error"){ forcePasswordResetMutate.reset(); addErrorMessage(`Error forcing password reset for ${account?.username}: ${forcePasswordResetMutate.error.message}`); console.log(forcePasswordResetMutate.error); diff --git a/src/ui/account/modals/RevokeRefreshTokenModal.tsx b/src/ui/account/modals/RevokeRefreshTokenModal.tsx index 8e3f86d..3b480af 100644 --- a/src/ui/account/modals/RevokeRefreshTokenModal.tsx +++ b/src/ui/account/modals/RevokeRefreshTokenModal.tsx @@ -25,12 +25,12 @@ export default function RevokeRefreshTokenModal({ } useEffect(() => { - if(revokeRefreshTokenMutate.isSuccess){ + if(revokeRefreshTokenMutate.status === "success"){ revokeRefreshTokenMutate.reset(); addSuccessMessage(`Refresh token for ${account?.username} was successfully revoked`); close(); } - else if(revokeRefreshTokenMutate.isError){ + else if(revokeRefreshTokenMutate.status === "error"){ revokeRefreshTokenMutate.reset(); addErrorMessage(`Error revoking refresh token for ${account?.username}: ${revokeRefreshTokenMutate.error.message}`); console.log(revokeRefreshTokenMutate.error); diff --git a/src/ui/game/GameAdminButtons.tsx b/src/ui/game/GameAdminButtons.tsx new file mode 100644 index 0000000..4092043 --- /dev/null +++ b/src/ui/game/GameAdminButtons.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 GameAdminButtons({ + buttonProps, + showEditGameModal, + showDeleteGameModal +}:{ + buttonProps: ButtonProps; + showEditGameModal: () => void; + showDeleteGameModal: () => void; +}){ + return ( +
+ + + + + + +
+ ); +} diff --git a/src/ui/game/GamesList.tsx b/src/ui/game/GamesList.tsx new file mode 100644 index 0000000..f7dca00 --- /dev/null +++ b/src/ui/game/GamesList.tsx @@ -0,0 +1,102 @@ +import { ButtonProps } from "@/components/button/Button"; +import Table from "@/components/table/Table"; +import { Game } from "@/interface/Game"; +import { useState } from "react"; +import GameAdminButtons from "./GameAdminButtons"; +import DeleteGameModal from "./modals/DeleteGameModal"; +import GameModal from "./modals/GameModal"; + + +export default function GamesList({ + games +}:{ + games: Game[]; +}){ + const [ selectedGame, setSelectedGame ] = useState(); + const [ displayEditGameModal, setDisplayEditGameModal ] = useState(false); + const [ displayDeleteGameModal, setDisplayDeleteGameModal ] = useState(false); + + + const buttonProps: ButtonProps = { + variant: "ghost", + size: "md", + shape: "square" + }; + + + const headElements: React.ReactNode[] = [ +
+ Icon +
, +
+ Name +
, +
+ Actions +
+ ]; + + const bodyElements: React.ReactNode[][] = games.map((game) => [ +
+ { + game.gameIcon && +
+ +
+ } +   +
, +
+ {game.gameName} +
, +
+
+   +
+ { + setSelectedGame(game); + setDisplayEditGameModal(true); + }} + showDeleteGameModal={() => { + setSelectedGame(game); + setDisplayDeleteGameModal(true); + }} + /> +
+ ]); + + + return ( + <> + + {setDisplayEditGameModal(false); setSelectedGame(undefined);}} + game={selectedGame} + /> + {setDisplayDeleteGameModal(false); setSelectedGame(undefined);}} + game={selectedGame} + /> + + ); +} diff --git a/src/ui/game/GamesListSkeleton.tsx b/src/ui/game/GamesListSkeleton.tsx new file mode 100644 index 0000000..f07251e --- /dev/null +++ b/src/ui/game/GamesListSkeleton.tsx @@ -0,0 +1,8 @@ +export default function GamesListSkeleton(){ + //TODO: + return ( +
+ Game List Skeleton +
+ ); +} diff --git a/src/ui/game/GamesLoader.tsx b/src/ui/game/GamesLoader.tsx new file mode 100644 index 0000000..c1c346f --- /dev/null +++ b/src/ui/game/GamesLoader.tsx @@ -0,0 +1,95 @@ +import PrimaryButton from "@/components/button/PrimaryButton"; +import TextInput from "@/components/input/TextInput"; +import DangerMessage from "@/components/message/DangerMessage"; +import Pagination from "@/components/pagination/Pagination"; +import { useGetGames, useGetGamesCount } from "@/hooks/GameHooks"; +import { useEffect, useState } from "react"; +import GamesList from "./GamesList"; +import GamesListSkeleton from "./GamesListSkeleton"; +import GameModal from "./modals/GameModal"; + + +export default function GamesLoader(){ + const [ displayCreateGameModal, setDisplayCreateGameModal ] = useState(false); + const [ page, setPage ] = useState(1); + const [ totalPages, setTotalPages ] = useState(1); + const [ searchTerm, setSearchTerm ] = useState(""); + const pageSize = 10; + const modalId = crypto.randomUUID().replace("-", ""); + + const gamesQuery = useGetGames(page - 1, pageSize); + const gamesCountQuery = useGetGamesCount(); + + + useEffect(() => { + if(gamesCountQuery.status === "success"){ + setTotalPages(Math.ceil(gamesCountQuery.data / pageSize)); + } + }, [ gamesCountQuery ]); + + + if(gamesQuery.status === "pending"){ + return + } + else if(gamesQuery.status === "error"){ + return Error {gamesQuery.error.message} + } + else{ + return ( + <> +
+
+   +
+ {/* Add Game Button */} +
+ setDisplayCreateGameModal(true)} + > + Create Game + + setDisplayCreateGameModal(false)} + game={undefined} + /> +
+ {/* Game Search Box */} +
+
+ setSearchTerm(e.target.value)} + placeholder="Search" + /> +
+
+
+ {/* Game List */} + + {/* Pagination */} +
+ +
+ + ); + } +} diff --git a/src/ui/game/modals/DeleteGameModal.tsx b/src/ui/game/modals/DeleteGameModal.tsx new file mode 100644 index 0000000..cb14316 --- /dev/null +++ b/src/ui/game/modals/DeleteGameModal.tsx @@ -0,0 +1,62 @@ +import DangerButton from "@/components/button/DangerButton"; +import SecondaryButton from "@/components/button/SecondaryButton"; +import RaidBuilderModal from "@/components/modal/RaidBuilderModal"; +import { useDeleteGame } from "@/hooks/GameHooks"; +import { Game } from "@/interface/Game"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect } from "react"; + +export default function DeleteGameModal({ + display, + close, + game +}:{ + display: boolean; + close: () => void; + game: Game | undefined; +}){ + const deleteGameMutate = useDeleteGame(); + const { addSuccessMessage, addErrorMessage } = useTimedModal(); + + + const deleteGame = () => { + deleteGameMutate.mutate(game?.gameId ?? ""); + } + + useEffect(() => { + if(deleteGameMutate.status === "success"){ + deleteGameMutate.reset(); + addSuccessMessage(`Successfully delete ${game?.gameName}`); + close(); + } + else if(deleteGameMutate.status === "error"){ + deleteGameMutate.reset(); + addErrorMessage(`Error deleting game ${game?.gameName}: ${deleteGameMutate.error.message}`); + console.log(deleteGameMutate.error); + } + }); + + + return ( + + + Delete + + + Cancel + + + } + /> + ); +} diff --git a/src/ui/game/modals/GameModal.tsx b/src/ui/game/modals/GameModal.tsx new file mode 100644 index 0000000..279b757 --- /dev/null +++ b/src/ui/game/modals/GameModal.tsx @@ -0,0 +1,113 @@ +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 { useCreateGame, useUpdateGame } from "@/hooks/GameHooks"; +import { Game } from "@/interface/Game"; +import { useTimedModal } from "@/providers/TimedModalProvider"; +import { useEffect, useState } from "react"; + + +export default function GameModal({ + display, + close, + game +}:{ + display: boolean; + close: () => void; + game?: Game; +}){ + const [ gameName, setGameName ] = useState(game?.gameName); + const [ gameIcon, setGameIcon ] = useState(game?.gameIcon); + const [ iconFile, setIconFile ] = useState(null); + const modalId = crypto.randomUUID().replace("-", ""); + + + useEffect(() => { + setGameName(game?.gameName ?? ""); + setGameIcon(game?.gameIcon ?? ""); + }, [ game, setGameName, setGameIcon ]); + + + const updateGameMutate = useUpdateGame(); + const createGameMutate = useCreateGame(); + const { addSuccessMessage, addErrorMessage } = useTimedModal(); + + + useEffect(() => { + if(updateGameMutate.status === "success"){ + updateGameMutate.reset(); + addSuccessMessage("Game updated successfully"); + close(); + } + else if(createGameMutate.status === "success"){ + createGameMutate.reset(); + addSuccessMessage("Game created successfully"); + close(); + } + else if(updateGameMutate.status === "error"){ + updateGameMutate.reset(); + addErrorMessage(`Error updating game ${gameName}: ${updateGameMutate.error.message}`); + console.log(updateGameMutate.error); + } + else if(createGameMutate.status === "error"){ + createGameMutate.reset(); + addErrorMessage(`Error creating game ${gameName}: ${createGameMutate.error.message}`); + console.log(createGameMutate.error); + } + }, [ updateGameMutate, createGameMutate, gameName, close, addSuccessMessage, addErrorMessage ]); + + + const updateGame = () => { + updateGameMutate.mutate({game: {gameId: game?.gameId, gameName, gameIcon} as Game, iconFile}); + } + + const createGame = () => { + createGameMutate.mutate({gameName: gameName ?? "", iconFile}); + } + + + return ( + +
+ setGameName(e.target.value)} + /> +
+ {setIconFile(file); setGameIcon(undefined);}} + addErrorMessage={addErrorMessage} + /> + + } + modalFooter={ + <> + + {game ? "Update" : "Create"} + + + Cancel + + + } + /> + ); +}