From 31c7bd2838dad1c864057b12afbc5c161a83320d Mon Sep 17 00:00:00 2001 From: Sean Hatfield <seanhatfield5@gmail.com> Date: Wed, 21 Feb 2024 11:20:36 -0800 Subject: [PATCH] [REFACTOR] Refactor UserMenu component for readability (#767) * refactor UserMenu component for readability * revisit hook --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- .../UserMenu/AccountModal/index.jsx | 170 ++++++++++ .../components/UserMenu/UserButton/index.jsx | 129 ++++++++ frontend/src/components/UserMenu/index.jsx | 304 +----------------- frontend/src/hooks/useLoginMode.js | 18 ++ 4 files changed, 318 insertions(+), 303 deletions(-) create mode 100644 frontend/src/components/UserMenu/AccountModal/index.jsx create mode 100644 frontend/src/components/UserMenu/UserButton/index.jsx create mode 100644 frontend/src/hooks/useLoginMode.js diff --git a/frontend/src/components/UserMenu/AccountModal/index.jsx b/frontend/src/components/UserMenu/AccountModal/index.jsx new file mode 100644 index 000000000..cdd96a76f --- /dev/null +++ b/frontend/src/components/UserMenu/AccountModal/index.jsx @@ -0,0 +1,170 @@ +import usePfp from "@/hooks/usePfp"; +import System from "@/models/system"; +import { AUTH_USER } from "@/utils/constants"; +import showToast from "@/utils/toast"; +import { Plus, X } from "@phosphor-icons/react"; + +export default function AccountModal({ user, hideModal }) { + const { pfp, setPfp } = usePfp(); + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return false; + + const formData = new FormData(); + formData.append("file", file); + const { success, error } = await System.uploadPfp(formData); + if (!success) { + showToast(`Failed to upload profile picture: ${error}`, "error"); + return; + } + + const pfpUrl = await System.fetchPfp(user.id); + setPfp(pfpUrl); + showToast("Profile picture uploaded.", "success"); + }; + + const handleRemovePfp = async () => { + const { success, error } = await System.removePfp(); + if (!success) { + showToast(`Failed to remove profile picture: ${error}`, "error"); + return; + } + + setPfp(null); + }; + + const handleUpdate = async (e) => { + e.preventDefault(); + + const data = {}; + const form = new FormData(e.target); + for (var [key, value] of form.entries()) { + if (!value || value === null) continue; + data[key] = value; + } + + const { success, error } = await System.updateUser(data); + if (success) { + let storedUser = JSON.parse(localStorage.getItem(AUTH_USER)); + + if (storedUser) { + storedUser.username = data.username; + localStorage.setItem(AUTH_USER, JSON.stringify(storedUser)); + } + showToast("Profile updated.", "success", { clear: true }); + hideModal(); + } else { + showToast(`Failed to update user: ${error}`, "error"); + } + }; + + return ( + <div + id="account-modal" + className="bg-black/60 backdrop-blur-sm fixed top-0 left-0 outline-none w-screen h-screen flex items-center justify-center" + > + <div className="relative w-[500px] max-w-2xl max-h-full bg-main-gradient rounded-lg shadow"> + <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> + <h3 className="text-xl font-semibold text-white">Edit Account</h3> + <button + onClick={hideModal} + type="button" + className="text-gray-400 bg-transparent hover:border-white/60 rounded-lg p-1.5 ml-auto inline-flex items-center hover:bg-menu-item-selected-gradient hover:border-slate-100 border-transparent" + > + <X className="text-lg" /> + </button> + </div> + <form onSubmit={handleUpdate} className="space-y-6"> + <div className="flex flex-col md:flex-row items-center justify-center gap-8"> + <div className="flex flex-col items-center"> + <label className="w-48 h-48 flex flex-col items-center justify-center bg-zinc-900/50 transition-all duration-300 rounded-full mt-8 border-2 border-dashed border-white border-opacity-60 cursor-pointer hover:opacity-60"> + <input + id="logo-upload" + type="file" + accept="image/*" + className="hidden" + onChange={handleFileUpload} + /> + {pfp ? ( + <img + src={pfp} + alt="User profile picture" + className="w-48 h-48 rounded-full object-cover bg-white" + /> + ) : ( + <div className="flex flex-col items-center justify-center p-3"> + <Plus className="w-8 h-8 text-white/80 m-2" /> + <span className="text-white text-opacity-80 text-sm font-semibold"> + Profile Picture + </span> + <span className="text-white text-opacity-60 text-xs"> + 800 x 800 + </span> + </div> + )} + </label> + {pfp && ( + <button + type="button" + onClick={handleRemovePfp} + className="mt-3 text-white text-opacity-60 text-sm font-medium hover:underline" + > + Remove Profile Picture + </button> + )} + </div> + </div> + <div className="flex flex-col gap-y-4 px-6"> + <div> + <label + htmlFor="username" + className="block mb-2 text-sm font-medium text-white" + > + Username + </label> + <input + name="username" + type="text" + className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + placeholder="User's username" + minLength={2} + defaultValue={user.username} + required + autoComplete="off" + /> + </div> + <div> + <label + htmlFor="password" + className="block mb-2 text-sm font-medium text-white" + > + New Password + </label> + <input + name="password" + type="password" + className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" + placeholder={`${user.username}'s new password`} + /> + </div> + </div> + <div className="flex justify-between items-center border-t border-gray-500/50 pt-4 p-6"> + <button + onClick={hideModal} + type="button" + className="px-4 py-2 rounded-lg text-white bg-transparent hover:bg-stone-900" + > + Cancel + </button> + <button + type="submit" + className="px-4 py-2 rounded-lg text-white bg-transparent border border-slate-200 hover:bg-slate-200 hover:text-slate-800" + > + Update Account + </button> + </div> + </form> + </div> + </div> + ); +} diff --git a/frontend/src/components/UserMenu/UserButton/index.jsx b/frontend/src/components/UserMenu/UserButton/index.jsx new file mode 100644 index 000000000..99db94411 --- /dev/null +++ b/frontend/src/components/UserMenu/UserButton/index.jsx @@ -0,0 +1,129 @@ +import useLoginMode from "@/hooks/useLoginMode"; +import usePfp from "@/hooks/usePfp"; +import useUser from "@/hooks/useUser"; +import System from "@/models/system"; +import paths from "@/utils/paths"; +import { userFromStorage } from "@/utils/request"; +import { Person } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import AccountModal from "../AccountModal"; +import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; + +export default function UserButton() { + const mode = useLoginMode(); + const { user } = useUser(); + const menuRef = useRef(); + const buttonRef = useRef(); + const [showMenu, setShowMenu] = useState(false); + const [showAccountSettings, setShowAccountSettings] = useState(false); + const [supportEmail, setSupportEmail] = useState(""); + + const handleClose = (event) => { + if ( + menuRef.current && + !menuRef.current.contains(event.target) && + !buttonRef.current.contains(event.target) + ) { + setShowMenu(false); + } + }; + + const handleOpenAccountModal = () => { + setShowAccountSettings(true); + setShowMenu(false); + }; + + useEffect(() => { + if (showMenu) { + document.addEventListener("mousedown", handleClose); + } + return () => document.removeEventListener("mousedown", handleClose); + }, [showMenu]); + + useEffect(() => { + const fetchSupportEmail = async () => { + const supportEmail = await System.fetchSupportEmail(); + setSupportEmail( + supportEmail?.email + ? `mailto:${supportEmail.email}` + : paths.mailToMintplex() + ); + }; + fetchSupportEmail(); + }, []); + + if (mode === null) return null; + return ( + <div className="absolute top-9 right-10 w-fit h-fit z-99"> + <button + ref={buttonRef} + onClick={() => setShowMenu(!showMenu)} + type="button" + className="uppercase transition-all duration-300 w-[35px] h-[35px] text-base font-semibold rounded-full flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient justify-center text-white p-2 hover:border-slate-100 hover:border-opacity-50 border-transparent border" + > + {mode === "multi" ? userDisplay() : <Person size={14} />} + </button> + + {showMenu && ( + <div + ref={menuRef} + className="w-fit rounded-lg absolute top-12 right-0 bg-sidebar p-4 flex items-center-justify-center" + > + <div className="flex flex-col gap-y-2"> + {mode === "multi" && !!user && ( + <button + onClick={handleOpenAccountModal} + className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" + > + Account + </button> + )} + <a + href={supportEmail} + className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" + > + Support + </a> + <button + onClick={() => { + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); + window.localStorage.removeItem(AUTH_TIMESTAMP); + window.location.replace(paths.home()); + }} + type="button" + className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" + > + Sign out + </button> + </div> + </div> + )} + {user && showAccountSettings && ( + <AccountModal + user={user} + hideModal={() => setShowAccountSettings(false)} + /> + )} + </div> + ); +} + +function userDisplay() { + const { pfp } = usePfp(); + const user = userFromStorage(); + + if (pfp) { + return ( + <div className="w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden transition-all duration-300 bg-gray-100 hover:border-slate-100 hover:border-opacity-50 border-transparent border hover:opacity-60"> + <img + src={pfp} + alt="User profile picture" + className="w-full h-full object-cover" + /> + </div> + ); + } + + return user?.username?.slice(0, 2) || "AA"; +} diff --git a/frontend/src/components/UserMenu/index.jsx b/frontend/src/components/UserMenu/index.jsx index 85446a218..539bdd474 100644 --- a/frontend/src/components/UserMenu/index.jsx +++ b/frontend/src/components/UserMenu/index.jsx @@ -1,13 +1,5 @@ -import React, { useState, useEffect, useRef } from "react"; import { isMobile } from "react-device-detect"; -import paths from "@/utils/paths"; -import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; -import { Person, Plus, X } from "@phosphor-icons/react"; -import { userFromStorage } from "@/utils/request"; -import useUser from "@/hooks/useUser"; -import System from "@/models/system"; -import showToast from "@/utils/toast"; -import usePfp from "@/hooks/usePfp"; +import UserButton from "./UserButton"; export default function UserMenu({ children }) { if (isMobile) return <>{children}</>; @@ -19,297 +11,3 @@ export default function UserMenu({ children }) { </div> ); } - -function useLoginMode() { - const user = !!window.localStorage.getItem(AUTH_USER); - const token = !!window.localStorage.getItem(AUTH_TOKEN); - - if (user && token) return "multi"; - if (!user && token) return "single"; - return null; -} - -function userDisplay() { - const { pfp } = usePfp(); - const user = userFromStorage(); - - if (pfp) { - return ( - <div className="w-[35px] h-[35px] rounded-full flex-shrink-0 overflow-hidden transition-all duration-300 bg-gray-100 hover:border-slate-100 hover:border-opacity-50 border-transparent border hover:opacity-60"> - <img - src={pfp} - alt="User profile picture" - className="w-full h-full object-cover" - /> - </div> - ); - } - - return user?.username?.slice(0, 2) || "AA"; -} - -function UserButton() { - const { user } = useUser(); - const [showMenu, setShowMenu] = useState(false); - const [showAccountSettings, setShowAccountSettings] = useState(false); - const [supportEmail, setSupportEmail] = useState(""); - const mode = useLoginMode(); - const menuRef = useRef(); - const buttonRef = useRef(); - const handleClose = (event) => { - if ( - menuRef.current && - !menuRef.current.contains(event.target) && - !buttonRef.current.contains(event.target) - ) { - setShowMenu(false); - } - }; - - const handleOpenAccountModal = () => { - setShowAccountSettings(true); - setShowMenu(false); - }; - - useEffect(() => { - if (showMenu) { - document.addEventListener("mousedown", handleClose); - } - return () => document.removeEventListener("mousedown", handleClose); - }, [showMenu]); - - useEffect(() => { - const fetchSupportEmail = async () => { - const supportEmail = await System.fetchSupportEmail(); - if (supportEmail.email) { - setSupportEmail(`mailto:${supportEmail.email}`); - } else { - setSupportEmail(paths.mailToMintplex()); - } - }; - fetchSupportEmail(); - }, []); - - if (mode === null) return null; - - return ( - <div className="absolute top-9 right-10 w-fit h-fit z-99"> - <button - ref={buttonRef} - onClick={() => setShowMenu(!showMenu)} - type="button" - className="uppercase transition-all duration-300 w-[35px] h-[35px] text-base font-semibold rounded-full flex items-center bg-sidebar-button hover:bg-menu-item-selected-gradient justify-center text-white p-2 hover:border-slate-100 hover:border-opacity-50 border-transparent border" - > - {mode === "multi" ? userDisplay() : <Person size={14} />} - </button> - - {showMenu && ( - <div - ref={menuRef} - className="w-fit rounded-lg absolute top-12 right-0 bg-sidebar p-4 flex items-center-justify-center" - > - <div className="flex flex-col gap-y-2"> - {mode === "multi" && !!user && ( - <button - onClick={handleOpenAccountModal} - className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" - > - Account - </button> - )} - <a - href={supportEmail} - className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" - > - Support - </a> - <button - onClick={() => { - window.localStorage.removeItem(AUTH_USER); - window.localStorage.removeItem(AUTH_TOKEN); - window.localStorage.removeItem(AUTH_TIMESTAMP); - window.location.replace(paths.home()); - }} - type="button" - className="text-white hover:bg-slate-200/20 w-full text-left px-4 py-1.5 rounded-md" - > - Sign out - </button> - </div> - </div> - )} - {user && showAccountSettings && ( - <AccountModal - user={user} - hideModal={() => setShowAccountSettings(false)} - /> - )} - </div> - ); -} - -function AccountModal({ user, hideModal }) { - const { pfp, setPfp } = usePfp(); - const handleFileUpload = async (event) => { - const file = event.target.files[0]; - if (!file) return false; - - const formData = new FormData(); - formData.append("file", file); - const { success, error } = await System.uploadPfp(formData); - if (!success) { - showToast(`Failed to upload profile picture: ${error}`, "error"); - return; - } - - const pfpUrl = await System.fetchPfp(user.id); - setPfp(pfpUrl); - - showToast("Profile picture uploaded successfully.", "success"); - }; - - const handleRemovePfp = async () => { - const { success, error } = await System.removePfp(); - if (!success) { - showToast(`Failed to remove profile picture: ${error}`, "error"); - return; - } - - setPfp(null); - showToast("Profile picture removed successfully.", "success"); - }; - - const handleUpdate = async (e) => { - e.preventDefault(); - - const data = {}; - const form = new FormData(e.target); - for (var [key, value] of form.entries()) { - if (!value || value === null) continue; - data[key] = value; - } - - const { success, error } = await System.updateUser(data); - if (success) { - let storedUser = JSON.parse(localStorage.getItem(AUTH_USER)); - - if (storedUser) { - storedUser.username = data.username; - localStorage.setItem(AUTH_USER, JSON.stringify(storedUser)); - } - window.location.reload(); - } else { - showToast(`Failed to update user: ${error}`, "error"); - } - }; - - return ( - <div - id="account-modal" - className="bg-black/60 backdrop-blur-sm fixed top-0 left-0 outline-none w-screen h-screen flex items-center justify-center" - > - <div className="relative w-[500px] max-w-2xl max-h-full bg-main-gradient rounded-lg shadow"> - <div className="flex items-start justify-between p-4 border-b rounded-t border-gray-500/50"> - <h3 className="text-xl font-semibold text-white">Edit Account</h3> - <button - onClick={hideModal} - type="button" - className="text-gray-400 bg-transparent hover:border-white/60 rounded-lg p-1.5 ml-auto inline-flex items-center hover:bg-menu-item-selected-gradient hover:border-slate-100 border-transparent" - > - <X className="text-lg" /> - </button> - </div> - <form onSubmit={handleUpdate} className="space-y-6"> - <div className="flex flex-col md:flex-row items-center justify-center gap-8"> - <div className="flex flex-col items-center"> - <label className="w-48 h-48 flex flex-col items-center justify-center bg-zinc-900/50 transition-all duration-300 rounded-full mt-8 border-2 border-dashed border-white border-opacity-60 cursor-pointer hover:opacity-60"> - <input - id="logo-upload" - type="file" - accept="image/*" - className="hidden" - onChange={handleFileUpload} - /> - {pfp ? ( - <img - src={pfp} - alt="User profile picture" - className="w-48 h-48 rounded-full object-cover bg-white" - /> - ) : ( - <div className="flex flex-col items-center justify-center p-3"> - <Plus className="w-8 h-8 text-white/80 m-2" /> - <span className="text-white text-opacity-80 text-sm font-semibold"> - Profile Picture - </span> - <span className="text-white text-opacity-60 text-xs"> - 800 x 800 - </span> - </div> - )} - </label> - {pfp && ( - <button - type="button" - onClick={handleRemovePfp} - className="mt-3 text-white text-opacity-60 text-sm font-medium hover:underline" - > - Remove Profile Picture - </button> - )} - </div> - </div> - <div className="flex flex-col gap-y-4 px-6"> - <div> - <label - htmlFor="username" - className="block mb-2 text-sm font-medium text-white" - > - Username - </label> - <input - name="username" - type="text" - className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" - placeholder="User's username" - minLength={2} - defaultValue={user.username} - required - autoComplete="off" - /> - </div> - <div> - <label - htmlFor="password" - className="block mb-2 text-sm font-medium text-white" - > - New Password - </label> - <input - name="password" - type="password" - className="bg-zinc-900 border border-gray-500 text-white text-sm rounded-lg block w-full p-2.5" - placeholder={`${user.username}'s new password`} - /> - </div> - </div> - <div className="flex justify-between items-center border-t border-gray-500/50 pt-4 p-6"> - <button - onClick={hideModal} - type="button" - className="px-4 py-2 rounded-lg text-white bg-transparent hover:bg-stone-900" - > - Cancel - </button> - <button - type="submit" - className="px-4 py-2 rounded-lg text-white bg-transparent border border-slate-200 hover:bg-slate-200 hover:text-slate-800" - > - Update Account - </button> - </div> - </form> - </div> - </div> - ); -} diff --git a/frontend/src/hooks/useLoginMode.js b/frontend/src/hooks/useLoginMode.js new file mode 100644 index 000000000..786a59f14 --- /dev/null +++ b/frontend/src/hooks/useLoginMode.js @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; +import { AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; + +export default function useLoginMode() { + const [mode, setMode] = useState(null); + + useEffect(() => { + if (!window) return; + const user = !!window.localStorage.getItem(AUTH_USER); + const token = !!window.localStorage.getItem(AUTH_TOKEN); + let _mode = null; + if (user && token) _mode = "multi"; + if (!user && token) _mode = "single"; + setMode(_mode); + }, [window]); + + return mode; +}