From c063029856865d4f4ac87fd7c7329ffcf92a6ddc Mon Sep 17 00:00:00 2001 From: BFlat Date: Thu, 8 Feb 2024 18:07:26 +0700 Subject: [PATCH] replaced logout/profile buttons with profile pic dropdown --- packages/components/src/elements/Gravatar.tsx | 2 + .../components/src/session/SessionWrapper.ts | 149 +++++++++++------- .../common/DropdownMenu/DropdownMenu.scss | 30 ++++ .../common/DropdownMenu/DropdownMenu.tsx | 79 ++++++++++ .../components/common/DropdownMenu/index.ts | 1 + .../common/NavItem/DropdownMenu.tsx | 40 ----- .../components/common/NavItem/NavItem.scss | 26 --- .../src/components/common/NavItem/NavItem.tsx | 6 +- .../src/components/common/NavItem/index.ts | 1 + .../ProfileDropdown/ProfileDropdown.scss | 16 ++ .../ProfileDropdown/ProfileDropdown.tsx | 86 ++++++++++ .../common/ProfileDropdown/index.ts | 1 + .../src/components/layout/TopNav/TopNav.tsx | 118 +++++--------- 13 files changed, 349 insertions(+), 206 deletions(-) create mode 100644 packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.scss create mode 100644 packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.tsx create mode 100644 packages/fakeyou.com/src/components/common/DropdownMenu/index.ts delete mode 100644 packages/fakeyou.com/src/components/common/NavItem/DropdownMenu.tsx create mode 100644 packages/fakeyou.com/src/components/common/NavItem/index.ts create mode 100644 packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.scss create mode 100644 packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.tsx create mode 100644 packages/fakeyou.com/src/components/common/ProfileDropdown/index.ts diff --git a/packages/components/src/elements/Gravatar.tsx b/packages/components/src/elements/Gravatar.tsx index a3e303ea23..009ddb0c5a 100644 --- a/packages/components/src/elements/Gravatar.tsx +++ b/packages/components/src/elements/Gravatar.tsx @@ -7,6 +7,7 @@ interface Props { username?: string; avatarIndex?: number; backgroundIndex?: number; + onClick?: () => void; } function Gravatar(props: Props) { @@ -35,6 +36,7 @@ function Gravatar(props: Props) { height={props.size} width={props.size} style={{ backgroundColor: getBackgroundColor(props.backgroundIndex) }} + onClick={props.onClick} /> ); } diff --git a/packages/components/src/session/SessionWrapper.ts b/packages/components/src/session/SessionWrapper.ts index 33b8c5d2e6..4df52de6d4 100644 --- a/packages/components/src/session/SessionWrapper.ts +++ b/packages/components/src/session/SessionWrapper.ts @@ -1,17 +1,21 @@ -import { GetSessionInfo, GetSessionInfoIsOk, SessionInfoSuccessResponse } from '../api/session/GetSessionInfo' -import { StudioRolloutHostnameAllowed } from '../utils/StudioRolloutHostnameAllowed'; +import { + GetSessionInfo, + GetSessionInfoIsOk, + SessionInfoSuccessResponse, +} from "../api/session/GetSessionInfo"; +import { StudioRolloutHostnameAllowed } from "../utils/StudioRolloutHostnameAllowed"; -// A lot of the APIs return null or leave values absent. +// A lot of the APIs return null or leave values absent. // I need to pick a single strategy and stick with it. type MaybeString = string | null | undefined; /** * SessionWrapper can wrap each of the following states: - * + * * 1) logged-in session * 2) non-existing session (logged out) * 3) lookup errors, server errors, 401, etc. (logged out) - * + * * It then makes frontend permission checking simple. */ export class SessionWrapper { @@ -24,7 +28,7 @@ export class SessionWrapper { } // Perform lookup and return a wrapper of a valid, inval - public static async lookupSession() : Promise { + public static async lookupSession(): Promise { let response = await GetSessionInfo(); if (GetSessionInfoIsOk(response)) { return SessionWrapper.wrapResponse(response); @@ -33,165 +37,198 @@ export class SessionWrapper { } } - public static emptySession() : SessionWrapper { + public static emptySession(): SessionWrapper { return new SessionWrapper(); } - public static wrapResponse(sessionStateResponse: SessionInfoSuccessResponse) : SessionWrapper { + public static wrapResponse( + sessionStateResponse: SessionInfoSuccessResponse + ): SessionWrapper { return new SessionWrapper(sessionStateResponse); } - public isLoggedIn() : boolean { + public isLoggedIn(): boolean { return this.sessionStateResponse?.logged_in || false; } // Feature flag rollout for Storyteller Studio // This depends on the domain name and the backend. - public canAccessStudio() : boolean { + public canAccessStudio(): boolean { const hostnameAllowed = StudioRolloutHostnameAllowed(); - const userAllowed = this.sessionStateResponse?.user?.can_access_studio || false; + const userAllowed = + this.sessionStateResponse?.user?.can_access_studio || false; return hostnameAllowed && userAllowed; } // Username is all lowercase - public getUsername() : string | undefined { + public getUsername(): string | undefined { return this.sessionStateResponse?.user?.username; } // Display name has capitalization - public getDisplayName() : string | undefined { + public getDisplayName(): string | undefined { return this.sessionStateResponse?.user?.display_name; } - public getEmailGravatarHash() : string | undefined { + public getEmailGravatarHash(): string | undefined { return this.sessionStateResponse?.user?.email_gravatar_hash; } - public canEditUserProfile(username: string) : boolean { + public canEditUserProfile(username: string): boolean { if (this.getUsername() === username) { return true; } return this.canEditOtherUsersProfiles(); } - public canEditMediaFileByUserToken(userToken: MaybeString) : boolean { - return this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken); + public canEditMediaFileByUserToken(userToken: MaybeString): boolean { + return ( + this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken) + ); } - public canEditWeightByUserToken(userToken: MaybeString) : boolean { - return this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken); + public canEditWeightByUserToken(userToken: MaybeString): boolean { + return ( + this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken) + ); } - public canEditTtsModelByUserToken(userToken: MaybeString) : boolean { - return this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken); + public canEditTtsModelByUserToken(userToken: MaybeString): boolean { + return ( + this.canEditOtherUsersTtsModels() || this.userTokenMatches(userToken) + ); } - public canDeleteTtsModelByUserToken(userToken: MaybeString) : boolean { - return this.canDeleteOtherUsersTtsModels() || this.userTokenMatches(userToken); + public canDeleteTtsModelByUserToken(userToken: MaybeString): boolean { + return ( + this.canDeleteOtherUsersTtsModels() || this.userTokenMatches(userToken) + ); } - public canDeleteTtsResultByUserToken(userToken: MaybeString) : boolean { - return this.canDeleteOtherUsersTtsResults() || this.userTokenMatches(userToken); + public canDeleteTtsResultByUserToken(userToken: MaybeString): boolean { + return ( + this.canDeleteOtherUsersTtsResults() || this.userTokenMatches(userToken) + ); } - public canDeleteW2lTemplateByUserToken(userToken: MaybeString) : boolean { - return this.canDeleteOtherUsersW2lTemplates() || this.userTokenMatches(userToken); + public canDeleteW2lTemplateByUserToken(userToken: MaybeString): boolean { + return ( + this.canDeleteOtherUsersW2lTemplates() || this.userTokenMatches(userToken) + ); } - public canDeleteW2lResultByUserToken(userToken: MaybeString) : boolean { - return this.canDeleteOtherUsersW2lResults() || this.userTokenMatches(userToken); + public canDeleteW2lResultByUserToken(userToken: MaybeString): boolean { + return ( + this.canDeleteOtherUsersW2lResults() || this.userTokenMatches(userToken) + ); } - public canUseTts() : boolean { + public canUseTts(): boolean { return this.sessionStateResponse?.user?.can_use_tts || true; // NB: Default true. } - public canUseW2l() : boolean { + public canUseW2l(): boolean { return this.sessionStateResponse?.user?.can_use_w2l || true; // NB: Default true } - public canUploadTtsModels() : boolean { + public canUploadTtsModels(): boolean { return this.sessionStateResponse?.user?.can_upload_tts_models || false; } - public canUploadW2lTemplates() : boolean { + public canUploadW2lTemplates(): boolean { return this.sessionStateResponse?.user?.can_upload_w2l_templates || false; } - public canBanUsers() : boolean { + public canBanUsers(): boolean { return this.sessionStateResponse?.user?.can_ban_users || false; } - public canEditCategories() : boolean { + public canEditCategories(): boolean { // TODO / NB: There aren't fine-grained controls over categories. return this.canBanUsers(); } - public canApproveW2lTemplates() : boolean { + public canApproveW2lTemplates(): boolean { return this.sessionStateResponse?.user?.can_approve_w2l_templates || false; } - public canEditOtherUsersProfiles() : boolean { - return this.sessionStateResponse?.user?.can_edit_other_users_profiles || false; + public canEditOtherUsersProfiles(): boolean { + return ( + this.sessionStateResponse?.user?.can_edit_other_users_profiles || false + ); } - public canEditOtherUsersTtsModels() : boolean { - return this.sessionStateResponse?.user?.can_edit_other_users_tts_models || false; + public canEditOtherUsersTtsModels(): boolean { + return ( + this.sessionStateResponse?.user?.can_edit_other_users_tts_models || false + ); } - public canDeleteOtherUsersTtsModels() : boolean { - return this.sessionStateResponse?.user?.can_delete_other_users_tts_models || false; + public canDeleteOtherUsersTtsModels(): boolean { + return ( + this.sessionStateResponse?.user?.can_delete_other_users_tts_models || + false + ); } - public canDeleteOtherUsersTtsResults() : boolean { - return this.sessionStateResponse?.user?.can_delete_other_users_tts_results || false; + public canDeleteOtherUsersTtsResults(): boolean { + return ( + this.sessionStateResponse?.user?.can_delete_other_users_tts_results || + false + ); } - public canDeleteOtherUsersW2lTemplates() : boolean { - return this.sessionStateResponse?.user?.can_delete_other_users_w2l_templates || false; + public canDeleteOtherUsersW2lTemplates(): boolean { + return ( + this.sessionStateResponse?.user?.can_delete_other_users_w2l_templates || + false + ); } - public canDeleteOtherUsersW2lResults() : boolean { - return this.sessionStateResponse?.user?.can_delete_other_users_w2l_results || false; + public canDeleteOtherUsersW2lResults(): boolean { + return ( + this.sessionStateResponse?.user?.can_delete_other_users_w2l_results || + false + ); } - - public canEditTtsResultAsUserOrMod(creatorUserToken: MaybeString) : boolean { + + public canEditTtsResultAsUserOrMod(creatorUserToken: MaybeString): boolean { if (this.userTokenMatches(creatorUserToken)) { return true; } return this.canDeleteOtherUsersTtsResults(); // TODO: There is no granular permission for this yet. } - public deleteTtsResultAsMod(creatorUserToken: MaybeString) : boolean { + public deleteTtsResultAsMod(creatorUserToken: MaybeString): boolean { if (this.userTokenMatches(creatorUserToken)) { return true; } return this.canDeleteOtherUsersTtsResults(); } - public deleteTtsModelAsMod(creatorUserToken: MaybeString) : boolean { + public deleteTtsModelAsMod(creatorUserToken: MaybeString): boolean { if (this.userTokenMatches(creatorUserToken)) { return true; } return this.canDeleteOtherUsersTtsModels(); } - public canEditW2lResultAsUserOrMod(creatorUserToken: MaybeString) : boolean { + public canEditW2lResultAsUserOrMod(creatorUserToken: MaybeString): boolean { if (this.userTokenMatches(creatorUserToken)) { return true; } return this.canDeleteOtherUsersW2lResults(); // TODO: There is no granular permission for this yet. } - public canDeleteW2lResultAsUserOrMod(creatorUserToken: MaybeString) : boolean { + public canDeleteW2lResultAsUserOrMod(creatorUserToken: MaybeString): boolean { if (this.userTokenMatches(creatorUserToken)) { return true; } return this.canDeleteOtherUsersW2lResults(); } - public userTokenMatches(otherUserToken: MaybeString) : boolean { - // Default to false if user token on either side is falsey. + public userTokenMatches(otherUserToken: MaybeString): boolean { + // Default to false if user token on either side is falsey. if (!otherUserToken) { return false; } diff --git a/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.scss b/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.scss new file mode 100644 index 0000000000..787078adbf --- /dev/null +++ b/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.scss @@ -0,0 +1,30 @@ +@import "scss/colours.scss"; + +.fy-dropdown-menu { + display: none; + position: absolute; + left: 0; + top: 100%; + background-color: $panel-inner; + border: 1px solid lighten($panel-inner, 8%); + border-radius: 0.5rem; + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + z-index: 1; + overflow: hidden; +} + +.fy-dropdown-item { + padding: 10px 15px; + text-decoration: none; + display: block; + color: $white !important; + transition: 0.15s all; + cursor: pointer; + font-size: 15px !important; + font-weight: 500 !important; +} + +.fy-dropdown-item:hover { + background-color: lighten($panel-inner, 8%); +} diff --git a/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.tsx b/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 0000000000..d3ae668014 --- /dev/null +++ b/packages/fakeyou.com/src/components/common/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { a, useSpring } from "@react-spring/web"; +import { Link } from "react-router-dom"; +import { IconDefinition } from "@fortawesome/fontawesome-common-types"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import "./DropdownMenu.scss"; + +interface DropdownItem { + id: number; + name: string; + link?: string; + onClick?: () => void; + icon?: IconDefinition; +} + +interface DropdownMenuProps { + items: DropdownItem[]; + className?: string; + onClose: () => void; +} + +export default function DropdownMenu({ + items, + className, + onClose, +}: DropdownMenuProps) { + const fadeIn = useSpring({ + from: { opacity: 0 }, + to: { opacity: 1 }, + config: { duration: 100 }, + }); + + return ( + + {items.map(item => ( + <> + {item.onClick ? ( +
{ + if (item.onClick) { + e.preventDefault(); + item.onClick(); + } + onClose(); + }} + className="fy-dropdown-item" + > + + {item.icon && ( + + )} + {item.name} + +
+ ) : ( + <> + {item.link && ( + onClose()} + > + + {item.icon && ( + + )} + {item.name} + + + )} + + )} + + ))} +
+ ); +} diff --git a/packages/fakeyou.com/src/components/common/DropdownMenu/index.ts b/packages/fakeyou.com/src/components/common/DropdownMenu/index.ts new file mode 100644 index 0000000000..7e4906822e --- /dev/null +++ b/packages/fakeyou.com/src/components/common/DropdownMenu/index.ts @@ -0,0 +1 @@ +export { default } from "./DropdownMenu"; diff --git a/packages/fakeyou.com/src/components/common/NavItem/DropdownMenu.tsx b/packages/fakeyou.com/src/components/common/NavItem/DropdownMenu.tsx deleted file mode 100644 index f145a2b2c9..0000000000 --- a/packages/fakeyou.com/src/components/common/NavItem/DropdownMenu.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { a, useSpring } from "@react-spring/web"; -import { Link } from "react-router-dom"; -import "./NavItem.scss"; -import { IconDefinition } from "@fortawesome/fontawesome-common-types"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -interface DropdownItem { - id: number; - name: string; - link: string; - icon?: IconDefinition; -} - -interface DropdownMenuProps { - items: DropdownItem[]; -} - -export default function DropdownMenu({ items }: DropdownMenuProps) { - const fadeIn = useSpring({ - from: { opacity: 0 }, - to: { opacity: 1 }, - config: { duration: 100 }, // Adjust timing as needed - }); - - return ( - - {items.map(item => ( - - - {item.icon && ( - - )} - {item.name} - - - ))} - - ); -} diff --git a/packages/fakeyou.com/src/components/common/NavItem/NavItem.scss b/packages/fakeyou.com/src/components/common/NavItem/NavItem.scss index 0b140c9a0c..7bb28aa022 100644 --- a/packages/fakeyou.com/src/components/common/NavItem/NavItem.scss +++ b/packages/fakeyou.com/src/components/common/NavItem/NavItem.scss @@ -17,32 +17,6 @@ } } -.fy-dropdown-menu { - display: none; - position: absolute; - left: 0; - top: 100%; - background-color: $panel-inner; - border: 1px solid lighten($panel-inner, 8%); - border-radius: 0.5rem; - min-width: 160px; - box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); - z-index: 1; - overflow: hidden; -} - .fy-nav-item:hover .fy-dropdown-menu { display: block; } - -.fy-dropdown-item { - padding: 10px 15px; - text-decoration: none; - display: block; - color: $white !important; - transition: 0.15s all; -} - -.fy-dropdown-item:hover { - background-color: lighten($panel-inner, 8%); -} diff --git a/packages/fakeyou.com/src/components/common/NavItem/NavItem.tsx b/packages/fakeyou.com/src/components/common/NavItem/NavItem.tsx index 37377483bc..c0a38350da 100644 --- a/packages/fakeyou.com/src/components/common/NavItem/NavItem.tsx +++ b/packages/fakeyou.com/src/components/common/NavItem/NavItem.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import DropdownMenu from "./DropdownMenu"; +import DropdownMenu from "../DropdownMenu/DropdownMenu"; import "./NavItem.scss"; import { IconDefinition } from "@fortawesome/fontawesome-common-types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -41,7 +41,9 @@ export default function NavItem({ {label} - {isHovered && } + {isHovered && ( + {}} /> + )} ); } else { diff --git a/packages/fakeyou.com/src/components/common/NavItem/index.ts b/packages/fakeyou.com/src/components/common/NavItem/index.ts new file mode 100644 index 0000000000..52fe1d829e --- /dev/null +++ b/packages/fakeyou.com/src/components/common/NavItem/index.ts @@ -0,0 +1 @@ +export { default } from "./NavItem"; diff --git a/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.scss b/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.scss new file mode 100644 index 0000000000..60857fa1af --- /dev/null +++ b/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.scss @@ -0,0 +1,16 @@ +.fy-profile-dropdown { + position: relative; + display: inline-block; + transition: 0.15s all; +} + +.fy-profile-pic { + cursor: pointer; +} + +.fy-profile-dropdown-menu { + display: block; + right: 0; + left: auto; + margin-top: 6px; +} diff --git a/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.tsx b/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.tsx new file mode 100644 index 0000000000..d27050e138 --- /dev/null +++ b/packages/fakeyou.com/src/components/common/ProfileDropdown/ProfileDropdown.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from "react"; +import DropdownMenu from "../DropdownMenu"; +import { Gravatar } from "@storyteller/components/src/elements/Gravatar"; +import "./ProfileDropdown.scss"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCaretDown, + faSignOutAlt, + faUser, +} from "@fortawesome/pro-solid-svg-icons"; + +interface ProfileDropdownProps { + username: string; + displayName: string; + avatarIndex: number; + backgroundColorIndex: number; + emailHash: string; + logoutHandler: () => void; +} + +export default function ProfileDropdown({ + username, + displayName, + avatarIndex, + backgroundColorIndex, + emailHash, + logoutHandler, +}: ProfileDropdownProps) { + const dropdownRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const profileOptions = [ + { + id: 1, + name: "My Profile", + link: `/profile/${displayName}`, + icon: faUser, + }, + { id: 2, name: "Logout", onClick: logoutHandler, icon: faSignOutAlt }, + ]; + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+
{ + setIsOpen(!isOpen); + }} + className="fy-profile-pic d-flex gap-2 align-items-center" + > + + +
+ + {isOpen && ( + { + setIsOpen(false); + }} + /> + )} +
+ ); +} diff --git a/packages/fakeyou.com/src/components/common/ProfileDropdown/index.ts b/packages/fakeyou.com/src/components/common/ProfileDropdown/index.ts new file mode 100644 index 0000000000..eec6053e9d --- /dev/null +++ b/packages/fakeyou.com/src/components/common/ProfileDropdown/index.ts @@ -0,0 +1 @@ +export { default } from "./ProfileDropdown"; diff --git a/packages/fakeyou.com/src/components/layout/TopNav/TopNav.tsx b/packages/fakeyou.com/src/components/layout/TopNav/TopNav.tsx index d51f781bbf..8efc96e915 100644 --- a/packages/fakeyou.com/src/components/layout/TopNav/TopNav.tsx +++ b/packages/fakeyou.com/src/components/layout/TopNav/TopNav.tsx @@ -3,9 +3,7 @@ import { faFaceViewfinder, faMessageDots, faSearch, - faSignOutAlt, faStar, - faUser, faWandMagicSparkles, faWaveformLines, faXmark, @@ -14,11 +12,11 @@ import { Button } from "components/common"; import SearchBar from "components/common/SearchBar"; import React, { useEffect, useState } from "react"; import { Link, useHistory } from "react-router-dom"; -import { WebUrl } from "common/WebUrl"; import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper"; import { Logout } from "@storyteller/components/src/api/session/Logout"; import { useDomainConfig } from "context/DomainConfigContext"; import NavItem from "../../common/NavItem/NavItem"; +import ProfileDropdown from "components/common/ProfileDropdown"; interface TopNavProps { sessionWrapper: SessionWrapper; @@ -101,91 +99,28 @@ export default function TopNav({ await Logout(); querySessionCallback(); querySessionSubscriptionsCallback(); - // PosthogClient.reset(); - // Analytics.accountLogout(); history.push("/"); }; const loggedIn = sessionWrapper.isLoggedIn(); - let userOrLoginButton = ( - <> -