replaced logout/profile buttons with profile pic dropdown

This commit is contained in:
BFlat committed 2024-02-08 18:07:26 +07:00
1 parent 754787a325
commit c063029856
13 files changed
+349 -206

No files matched your search

@@ -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}
/>
);
}
@@ -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<SessionWrapper> {
public static async lookupSession(): Promise<SessionWrapper> {
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;
}
@@ -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%);
}
@@ -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 (
<a.div style={fadeIn} className={`fy-dropdown-menu ${className}`.trim()}>
{items.map(item => (
<>
{item.onClick ? (
<div
key={item.id}
onClick={e => {
if (item.onClick) {
e.preventDefault();
item.onClick();
}
onClose();
}}
className="fy-dropdown-item"
>
<span>
{item.icon && (
<FontAwesomeIcon icon={item.icon} className="me-2 fs-7" />
)}
{item.name}
</span>
</div>
) : (
<>
{item.link && (
<Link
key={item.id}
to={item.link}
className="fy-dropdown-item"
onClick={() => onClose()}
>
<span>
{item.icon && (
<FontAwesomeIcon icon={item.icon} className="me-2 fs-7" />
)}
{item.name}
</span>
</Link>
)}
</>
)}
</>
))}
</a.div>
);
}
@@ -0,0 +1 @@
export { default } from "./DropdownMenu";
@@ -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 (
<a.div style={fadeIn} className="fy-dropdown-menu">
{items.map(item => (
<Link key={item.id} to={item.link} className="fy-dropdown-item">
<span>
{item.icon && (
<FontAwesomeIcon icon={item.icon} className="me-2 fs-7" />
)}
{item.name}
</span>
</Link>
))}
</a.div>
);
}
@@ -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%);
}
@@ -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} <FontAwesomeIcon icon={faCaretDown} className="ms-1 fs-7" />
</span>
{isHovered && <DropdownMenu items={dropdownItems} />}
{isHovered && (
<DropdownMenu items={dropdownItems} onClose={() => {}} />
)}
</>
);
} else {
@@ -0,0 +1 @@
export { default } from "./NavItem";
@@ -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;
}
@@ -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<HTMLDivElement>(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 (
<div className="fy-profile-dropdown" ref={dropdownRef}>
<div
onClick={() => {
setIsOpen(!isOpen);
}}
className="fy-profile-pic d-flex gap-2 align-items-center"
>
<Gravatar
size={38}
username={username}
email_hash={emailHash}
avatarIndex={avatarIndex}
backgroundIndex={backgroundColorIndex}
/>
<FontAwesomeIcon icon={faCaretDown} />
</div>
{isOpen && (
<DropdownMenu
items={profileOptions}
className="fy-profile-dropdown-menu"
onClose={() => {
setIsOpen(false);
}}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./ProfileDropdown";
@@ -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 = (
<>
<Button
label="Login"
small
variant="secondary"
onClick={() => {
history.push("/login");
}}
/>
</>
);
let signupOrLogOutButton = (
<>
<Button
label="Sign Up"
small
onClick={() => {
history.push("/signup");
}}
/>
</>
);
if (loggedIn) {
let displayName = sessionWrapper.getDisplayName();
// let gravatarHash = props.sessionWrapper.getEmailGravatarHash();
// let gravatar = <span />;
if (displayName === undefined) {
displayName = "My Account";
}
let url = WebUrl.userProfilePage(displayName);
userOrLoginButton = (
<>
<Button
icon={faUser}
label="My Profile"
small
variant="secondary"
onClick={() => {
history.push(url);
}}
/>
</>
);
signupOrLogOutButton = (
<>
<Button
icon={faSignOutAlt}
label="Logout"
small
variant="danger"
onClick={async () => {
await logoutHandler();
}}
/>
</>
);
}
let profileDropdown = <></>;
if (sessionWrapper.isLoggedIn()) {
let displayName = sessionWrapper.getDisplayName();
if (displayName === undefined) {
displayName = "My Account";
}
let url = WebUrl.userProfilePage(displayName);
userOrLoginButton = (
<Button
icon={faUser}
label="My Profile"
small
variant="secondary"
onClick={() => history.push(url)}
className="d-none d-lg-block"
let username = sessionWrapper.getUsername();
let emailHash = sessionWrapper.getEmailGravatarHash();
let avatarIndex = 0; //temporary
let backgroundColorIndex = 0; //temporary
profileDropdown = (
<ProfileDropdown
username={username || ""}
displayName={displayName || ""}
avatarIndex={avatarIndex}
backgroundColorIndex={backgroundColorIndex}
emailHash={emailHash || ""}
logoutHandler={logoutHandler}
/>
);
}
@@ -269,8 +204,27 @@ export default function TopNav({
<div className="d-flex align-items-center gap-2">
<div className="d-none d-lg-flex gap-2">
{userOrLoginButton}
{signupOrLogOutButton}
{loggedIn ? (
profileDropdown
) : (
<>
<Button
label="Login"
small
variant="secondary"
onClick={() => {
history.push("/login");
}}
/>
<Button
label="Sign Up"
small
onClick={() => {
history.push("/signup");
}}
/>
</>
)}
</div>
<Button
icon={faSearch}