Merge branch 'feature/mvp' of https://github.com/storytold/storyteller-frontend into feature/mvp

This commit is contained in:
Victor committed 2024-02-08 11:20:30 -08:00
commit 258f76591c
38 files changed
+2651 -1976

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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -29,8 +29,7 @@ export default function PageContainer({
}
// Merge the classNames
const mergedClassNames =
`${containerClass} ${className} min-height-100vh`.trim();
const mergedClassNames = `${containerClass} ${className}`.trim();
return <div className={mergedClassNames}>{children}</div>;
}
@@ -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";
@@ -0,0 +1,22 @@
@import "scss/colours.scss";
.fy-nav-item {
position: relative;
padding: 10px;
cursor: pointer;
font-size: 15px;
font-weight: 500;
color: rgba($white, 0.8);
transition: 0.15s all;
a {
color: rgba($white, 0.8);
font-size: 15px;
font-weight: 500;
transition: 0.15s all;
}
}
.fy-nav-item:hover .fy-dropdown-menu {
display: block;
}
@@ -0,0 +1,72 @@
import React, { useState } from "react";
import DropdownMenu from "../DropdownMenu/DropdownMenu";
import "./NavItem.scss";
import { IconDefinition } from "@fortawesome/fontawesome-common-types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown } from "@fortawesome/pro-solid-svg-icons";
import { Link } from "react-router-dom";
interface NavItemProps {
icon?: IconDefinition;
label: string;
isHoverable?: boolean;
link?: string;
className?: string;
dropdownItems?: {
id: number;
name: string;
link: string;
icon?: IconDefinition;
}[];
}
export default function NavItem({
label,
dropdownItems,
icon,
isHoverable,
link,
className,
}: NavItemProps) {
const [isHovered, setIsHovered] = useState(false);
const textHover = `${isHovered && "text-white"}`.trim();
const renderContent = () => {
if (isHoverable && dropdownItems) {
return (
<>
{icon && <FontAwesomeIcon icon={icon} className="me-2 fs-7" />}
<span className={textHover}>
{label} <FontAwesomeIcon icon={faCaretDown} className="ms-1 fs-7" />
</span>
{isHovered && (
<DropdownMenu items={dropdownItems} onClose={() => {}} />
)}
</>
);
} else {
return (
<>
{link && (
<Link to={link} className={textHover}>
{icon && <FontAwesomeIcon icon={icon} className="me-2 fs-7" />}
{label}
</Link>
)}
</>
);
}
};
return (
<div
className={`fy-nav-item ${className}`.trim()}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{renderContent()}
</div>
);
}
@@ -0,0 +1 @@
export { default } from "./NavItem";
@@ -20,8 +20,10 @@ export default function Panel({
return (
<div
className={`${clear ? "panel-clear" : "panel"}${
padding ? " p-3 py-4 p-md-4" : ""
}${mb ? " mb-4" : ""}${mt ? " mt-4" : ""}${className ? " " + className : ""}`}
padding ? " p-3 p-md-4" : ""
}${mb ? " mb-4" : ""}${mt ? " mt-4" : ""}${
className ? " " + className : ""
}`}
>
{children}
</div>
@@ -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";
@@ -85,6 +85,12 @@
}
}
@media (max-width: 1200px) {
.search-bar-container {
width: 400px;
}
}
@media (max-width: 992px) {
.search-bar-container {
width: 100%;
@@ -56,6 +56,13 @@ export default function SideNav({
const isDevelopmentEnv = fakeYouFrontendEnv.isDevelopment();
const wrapper = document.getElementById("wrapper");
const isMenuOpen = wrapper?.classList.contains("toggled");
const isLoggedIn = sessionWrapper.isLoggedIn();
const isOnLandingPage = window.location.pathname === "/";
const isOnLoginOrSignUpPage =
window.location.pathname === "/login" ||
window.location.pathname === "/login/" ||
window.location.pathname === "/signup" ||
window.location.pathname === "/signup/";
let history = useHistory();
const handleNavLinkClick = () => {
@@ -105,17 +112,25 @@ export default function SideNav({
};
}, []);
const shouldShowSidebar = windowWidth >= 992;
const shouldNotShowSidebar =
!isLoggedIn && (isOnLandingPage || isOnLoginOrSignUpPage);
const shouldShowSidebar = windowWidth >= 992 && !shouldNotShowSidebar;
const sidebarClassName = `sidebar ${
shouldShowSidebar ? "visible" : ""
}`.trim();
useEffect(() => {
const contentWrapper = document.getElementById("page-content-wrapper");
if (windowWidth >= 992) {
if (
(shouldShowSidebar && isLoggedIn) ||
(shouldShowSidebar && !isOnLandingPage)
) {
contentWrapper?.classList.remove("no-padding");
} else {
contentWrapper?.classList.add("no-padding");
}
}, [windowWidth]);
}, [isLoggedIn, isOnLandingPage, shouldShowSidebar]);
const [queueStats, setQueueStats] = useState<GetQueueStatsSuccessResponse>({
success: true,
@@ -349,10 +364,7 @@ export default function SideNav({
return (
<>
<div
id="sidebar-wrapper"
className={`sidebar ${shouldShowSidebar ? "visible" : ""}`}
>
<div id="sidebar-wrapper" className={sidebarClassName}>
<div>
<ul className="sidebar-nav">
<li>
@@ -558,7 +570,7 @@ export default function SideNav({
</div>
</div>
<div className="px-4 d-flex d-lg-none gap-2 mb-5">
<div className="px-4 d-flex d-lg-none gap-2 mb-2">
{userOrLoginButton}
{signupOrLogOutButton}
</div>
@@ -1,8 +1,11 @@
import {
faBars,
faFaceViewfinder,
faMessageDots,
faSearch,
faSignOutAlt,
faUser,
faStar,
faWandMagicSparkles,
faWaveformLines,
faXmark,
faClipboardList
} from "@fortawesome/pro-solid-svg-icons";
@@ -10,11 +13,13 @@ 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 { useModal } from "hooks";
import { InferenceJobsModal } from "components/modals";
import { useDomainConfig } from "context/DomainConfigContext";
import NavItem from "../../common/NavItem/NavItem";
import ProfileDropdown from "components/common/ProfileDropdown";
interface TopNavProps {
sessionWrapper: SessionWrapper;
@@ -28,12 +33,20 @@ export default function TopNav({
querySessionCallback,
querySessionSubscriptionsCallback,
}: TopNavProps) {
const domain = useDomainConfig();
let history = useHistory();
const [isMobileSearchBarVisible, setIsMobileSearchBarVisible] =
useState(false);
const [isFocused, setIsFocused] = useState(false);
const wrapper = document.getElementById("wrapper");
const [menuButtonIcon, setMenuButtonIcon] = useState(faBars);
// const { t } = useLocalize("TopNav");
const isOnLandingPage = window.location.pathname === "/";
const isOnLoginOrSignUpPage =
window.location.pathname === "/login" ||
window.location.pathname === "/login/" ||
window.location.pathname === "/signup" ||
window.location.pathname === "/signup/";
const { open } = useModal();
const openModal = () => open({ component: InferenceJobsModal });
@@ -92,113 +105,85 @@ 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}
/>
);
}
const aiToolsDropdown = [
{ id: 1, name: "Text to Speech", link: "/tts", icon: faMessageDots },
{
id: 2,
name: "Voice to Voice",
link: "/voice-conversion",
icon: faWaveformLines,
},
{
id: 3,
name: "Face Animator",
link: "/face-animator",
icon: faFaceViewfinder,
},
{
id: 4,
name: "Voice Designer",
link: "/voice-designer",
icon: faWandMagicSparkles,
},
// { id: 4, name: "Text to Image", link: "/text-to-image" },
];
return (
<div id="topbar-wrapper" className="position-fixed">
<div className="topbar-nav">
<div className="topbar-nav-left">
<Link to="/">
<img
src="/fakeyou/FakeYou-Logo.png"
alt="FakeYou: Cartoon and Celebrity Text to Speech"
height="34"
className="mb-2 d-none d-lg-block"
/>
<img
src="/fakeyou/FakeYou-Logo-Mobile.png"
alt="FakeYou: Cartoon and Celebrity Text to Speech"
height="36"
className="mb-0 d-block d-lg-none"
/>
</Link>
<div className="d-flex gap-3 align-items-center">
<Link to="/">
<img
src={domain.logo}
alt={`${domain.title}: Cartoon and Celebrity Text to Speech`}
height="34"
className="mb-1 d-none d-lg-block"
/>
<img
src="/fakeyou/FakeYou-Logo-Mobile.png"
alt={`${domain.title}: Cartoon and Celebrity Text to Speech`}
height="36"
className="mb-0 d-block d-lg-none"
/>
</Link>
{((!loggedIn && isOnLandingPage) ||
(!loggedIn && isOnLoginOrSignUpPage)) && (
<div className="d-none d-lg-block">
<NavItem
isHoverable={true}
label="AI Tools"
dropdownItems={aiToolsDropdown}
/>
</div>
)}
</div>
</div>
<div className="topbar-nav-center">
@@ -213,11 +198,40 @@ export default function TopNav({
</div>
<div className="topbar-nav-right">
{((!loggedIn && isOnLandingPage) ||
(!loggedIn && isOnLoginOrSignUpPage)) && (
<NavItem
icon={faStar}
label="Pricing"
link="/pricing"
className="me-3 d-none d-lg-block"
/>
)}
<div className="d-flex align-items-center gap-2">
<div className="d-none d-lg-flex gap-2">
<Button {...{ icon: faClipboardList, label: "My Jobs", onClick: openModal, variant: "secondary" }}/>
{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}
@@ -239,6 +253,8 @@ export default function TopNav({
</div>
</div>
{/* <div className="topbar-nav bg-panel">test</div> */}
{/* Mobile Searchbar */}
{isMobileSearchBarVisible && (
<div className="topbar-mobile-search-bar-container">
@@ -0,0 +1,22 @@
import React, { createContext, useContext, ReactNode } from "react";
import { getCurrentDomainConfig } from "../utils/domainConfig";
const DomainConfigContext = createContext(getCurrentDomainConfig());
export const useDomainConfig = () => useContext(DomainConfigContext);
interface DomainConfigProviderProps {
children: ReactNode;
}
export default function DomainConfigProvider({
children,
}: DomainConfigProviderProps) {
const domainConfig = getCurrentDomainConfig();
return (
<DomainConfigContext.Provider value={domainConfig}>
{children}
</DomainConfigContext.Provider>
);
}
+8 -1
View File
@@ -2,5 +2,12 @@ import AccountModalContext from "./AccountModalContext";
import InferenceJobsContext from "./InferenceJobsContext";
import ModalContext from "./ModalContext";
import SessionContext from "./SessionContext";
import DomainConfigContext from "./DomainConfigContext";
export { AccountModalContext, InferenceJobsContext, ModalContext, SessionContext };
export {
AccountModalContext,
InferenceJobsContext,
ModalContext,
SessionContext,
DomainConfigContext,
};
+1 -1
View File
@@ -12,7 +12,7 @@ $warning: darken(#f7941c, 5%);
$accent: #80cfd3;
$workflow: $primary;
$panel: #242433;
$panel-inner: lighten($panel, 4%);
$panel-inner: #2b2b3b;
$panel-inner-border: lighten($panel, 8%);
$primary-lm: #000;
+1
View File
@@ -60,6 +60,7 @@ $topbar-height: 66px;
position: relative;
padding-left: 260px;
padding-top: $topbar-height;
transition: all 0.2s ease;
}
/* Sidebar Styles */
+25 -25
View File
@@ -11,21 +11,21 @@ styles with important tags:
//
// Once the rollout is complete, this outer wrapper class can be removed.
@mixin panel {
background-color: $panel;
border-color: #ffffff12 !important;
border: 1px solid;
border-left: none;
border-right: none;
border-radius: 0px;
box-shadow:
0 0 0 1px rgba(0 0 0 / 16%),
0 70px 65px rgb(0 0 0 / 8%),
0 30px 30px rgb(0 0 0 / 7%),
0 15px 15px rgb(0 0 0 / 5%),
0 10px 8px rgb(0 0 0 / 3%),
0 4px 4px rgb(0 0 0 / 2%),
0 2px 2px rgb(0 0 0 / 1%) !important;
@mixin panel {
background-color: $panel;
border-color: #ffffff12 !important;
border: 1px solid;
border-left: none;
border-right: none;
border-radius: 0px;
box-shadow:
0 0 0 1px rgba(0 0 0 / 16%),
0 70px 65px rgb(0 0 0 / 8%),
0 30px 30px rgb(0 0 0 / 7%),
0 15px 15px rgb(0 0 0 / 5%),
0 10px 8px rgb(0 0 0 / 3%),
0 4px 4px rgb(0 0 0 / 2%),
0 2px 2px rgb(0 0 0 / 1%) !important;
}
.fakeyou-refresh {
@@ -45,11 +45,11 @@ styles with important tags:
margin: 0;
}
/* Firefox */
.numberInputNoArrows input[type=number],
input.numberInputNoArrows[type=number] {
.numberInputNoArrows input[type="number"],
input.numberInputNoArrows[type="number"] {
-moz-appearance: textfield;
}
//-end--//
::-webkit-scrollbar-thumb {
@@ -360,7 +360,7 @@ styles with important tags:
}
.panel-inner {
background-color: lighten($panel, 4%);
background-color: $panel-inner;
}
.panel-tts-results {
@@ -1373,15 +1373,15 @@ styles with important tags:
.hanashi-before {
position: absolute;
top: -35%;
left: -140%;
top: -80%;
left: -150%;
pointer-events: none;
}
.hanashi-after {
position: absolute;
top: -35%;
right: -140%;
top: -80%;
right: -150%;
pointer-events: none;
}
}
@@ -1696,8 +1696,8 @@ textarea {
}
.section {
padding-top: 7rem;
padding-bottom: 7rem;
padding-top: 6rem;
padding-bottom: 6rem;
}
}
}
@@ -0,0 +1,35 @@
type DomainConfig = Record<string, any>;
const domainConfigs: Record<string, DomainConfig> = {
"storyteller.ai": {
logo: "/fakeyou/Storyteller-Logo-5.png",
title: "Storyteller AI",
link: "https://storyteller.ai",
},
"fakeyou.com": {
logo: "/fakeyou/FakeYou-Logo-2.png",
title: "FakeYou",
link: "https://fakeyou.com",
},
// Default to FakeYou.com
default: {
logo: "/fakeyou/FakeYou-Logo-2.png",
title: "FakeYou",
link: "https://fakeyou.com",
},
};
export const getCurrentDomainConfig = (): DomainConfig => {
const currentHostname = window.location.hostname;
const matchingConfigKey = Object.keys(domainConfigs).find(
key => currentHostname === key || currentHostname.endsWith(`.${key}`)
);
const domainConfig =
matchingConfigKey !== undefined
? domainConfigs[matchingConfigKey]
: domainConfigs["default"];
return domainConfig;
};
File diff suppressed because it is too large. Load diff
@@ -19,12 +19,14 @@ import {
} from "@storyteller/components/src/api/server/GetServerInfo";
import { useLocalize } from "hooks";
import { Container } from "components/common";
import { useDomainConfig } from "context/DomainConfigContext";
interface Props {
sessionWrapper: SessionWrapper;
}
function FooterNav(props: Props) {
const domain = useDomainConfig();
const { t } = useLocalize("Footer");
const [serverInfo, setServerInfo] = useState<
GetServerInfoSuccessResponse | undefined
@@ -84,8 +86,8 @@ function FooterNav(props: Props) {
<div className="col-12 col-lg-3 d-flex flex-column gap-4 align-items-center align-items-lg-start">
<Link to="/">
<img
src="/fakeyou/FakeYou-Logo.png"
alt="FakeYou: Cartoon and Celebrity Text to Speech"
src={domain.logo}
alt={`${domain.title}: Cartoon and Celebrity Text to Speech`}
height="34"
/>
</Link>
@@ -204,12 +206,8 @@ function FooterNav(props: Props) {
</div>
<div className="d-flex flex-column flex-lg-row pt-2 align-items-center gap-2 gap-xl-4 flex-wrap">
<span className="flex-grow-1">
© 2023 FakeYou by{" "}
<a href="https://storyteller.ai" target="_blank" rel="noreferrer">
Storyteller.ai
</a>
</span>
<span className="flex-grow-1 opacity-75">© {domain.title} 2024</span>
<div className="d-flex flex-column flex-lg-row align-items-center mt-4 mt-lg-0">
{moderationLink}
</div>
@@ -0,0 +1,193 @@
import {
faArrowRight,
faFileArrowUp,
faFlask,
faSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { Panel } from "components/common";
import { useLocalize } from "hooks";
import React from "react";
import { Link } from "react-router-dom";
interface DashboardProps {
sessionWrapper: SessionWrapper;
}
export default function Dashboard(props: DashboardProps) {
const { t } = useLocalize("LandingPage");
const isLoggedIn = props.sessionWrapper.isLoggedIn();
let uploadModelSection = <></>;
if (isLoggedIn) {
uploadModelSection = (
<>
<h2 className="fw-bold mb-4 mt-5 pt-4">Upload Models</h2>
<div className="panel p-4 rounded">
<div className="row gy-3 zi-2">
<div className="col-12 col-lg-4">
<Link to="/upload/tts" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload TTS Model
</Link>
</div>
<div className="col-12 col-lg-4">
<Link to="/upload/voice_conversion" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload V2V Model
</Link>
</div>
<div className="col-12 col-lg-4">
<Link to="/upload/w2l_photo" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload W2L Model
</Link>
</div>
</div>
</div>
</>
);
}
return (
<Panel clear={true} className="mt-5">
<h1 className="fw-bold mb-4">{t("productsTitle")}</h1>
<div className="row g-4 position-relative">
<div className="col-12 col-md-4">
<Link
to="/tts"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<h3 className="fw-bold text-white mb-1">
{t("productTtsTitle")}
</h3>
<h6 className="fw-normal opacity-75 text-white">
{t("productTtsText")}
</h6>
</div>
<Link to="/tts" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-tts.webp"
alt="Text to speech"
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/voice-conversion"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<h3 className="fw-bold text-white mb-1">
{t("productVcTitle")}
</h3>
<h6 className="fw-normal opacity-75 text-white">
{t("productVcText")}
</h6>
</div>
<Link to="/voice-conversion" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-vc.webp"
alt="Voice Conversion"
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/voice-designer"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<div className="mb-1">
<span className="badge-beta d-inline-flex align-items-center mb-2 me-2">
<FontAwesomeIcon icon={faFlask} className="me-1" />
BETA
</span>
<h4 className="fw-bold text-white d-inline-flex align-items-center mb-0">
<span>Voice Designer</span>
</h4>
</div>
<h6 className="fw-normal opacity-75 text-white">
Create your own AI voice
</h6>
</div>
<Link to="/voice-designer" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-vd.webp"
alt="Voice Designer"
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/face-animator"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<div className="mb-1">
<span className="badge-new d-inline-flex align-items-center mb-2 me-2">
<FontAwesomeIcon icon={faSparkles} className="me-1" />
{t("productNewTag")}
</span>
<h4 className="fw-bold text-white d-inline-flex align-items-center mb-0">
<span>{t("productFaceAnimatorTitle")}</span>
</h4>
</div>
<h6 className="fw-normal opacity-75 text-white">
{t("productFaceAnimatorText")}
</h6>
</div>
<Link to="/face-animator" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-w2l.webp"
alt="Face Animator"
/>
</Link>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-right-top"
/>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom"
/>
</div>
{uploadModelSection}
</Panel>
);
}
@@ -1,25 +1,15 @@
import React, { useMemo } from "react";
import React from "react";
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import { Link } from "react-router-dom";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { useLocalize } from "hooks";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faStar,
faArrowRight,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { faDiscord, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { WebUrl } from "../../../../common/WebUrl";
import {
faFileArrowUp,
faFlask,
faSparkles,
} from "@fortawesome/pro-solid-svg-icons";
import {} from "@fortawesome/pro-solid-svg-icons";
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import Alert from "components/common/Alert/Alert";
// import { Analytics } from "../../../../../common/Analytics";
import { Container } from "components/common";
import FakeYouLandingHeader from "./fakeyou/FakeYouLandingHeader";
import Dashboard from "./Dashboard";
import FakeYouLandingBody from "./fakeyou/FakeYouLandingBody";
import { useDomainConfig } from "context/DomainConfigContext";
import StorytellerLanding from "./storyteller/StorytellerLanding";
interface Props {
sessionWrapper: SessionWrapper;
@@ -28,537 +18,40 @@ interface Props {
function LandingPage(props: Props) {
usePrefixedDocumentTitle("FakeYou Celebrity Voice Generator");
PosthogClient.recordPageview();
const domain = useDomainConfig();
const { t } = useLocalize("LandingPage");
let signUpButton = <></>;
let viewPricingButton = <></>;
let upgradeButton = <></>;
let myProfileButton = <></>;
let uploadModelSection = <></>;
if (!props.sessionWrapper.isLoggedIn()) {
signUpButton = (
<>
<Link
to="/signup"
// onClick={() => {
// Analytics.ttsClickHeroSignup();
// }}
>
<button type="button" className="btn btn-primary w-100">
{t("heroButtonSignUp")}
<FontAwesomeIcon icon={faArrowRight} className="ms-2" />
</button>
</Link>
</>
);
viewPricingButton = (
<>
<Link
to={WebUrl.pricingPageWithReferer("tts_hero_new")}
// onClick={() => {
// Analytics.ttsClickHeroViewPricing();
// }}
>
<button type="button" className="btn btn-secondary w-100">
<FontAwesomeIcon icon={faStar} className="me-2" />
{t("heroButtonPricing")}
</button>
</Link>
</>
);
}
if (props.sessionWrapper.isLoggedIn()) {
let displayName = props.sessionWrapper.getDisplayName() as string; // NB: If logged in, should be string
let url = WebUrl.userProfilePage(displayName);
myProfileButton = (
<>
<Link
to={url}
// onClick={() => {
// Analytics.ttsClickHeroViewProfile();
// }}
>
<button type="button" className="btn btn-secondary w-100">
<FontAwesomeIcon icon={faUser} className="me-2" />
{t("heroButtonProfile")}
</button>
</Link>
</>
);
uploadModelSection = (
<>
<h2 className="fw-bold mb-4 mt-5 pt-4">Upload Models</h2>
<div className="panel p-4 rounded">
<div className="row gy-3 zi-2">
<div className="col-12 col-lg-4">
<Link to="/upload/tts" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload TTS Model
</Link>
</div>
<div className="col-12 col-lg-4">
<Link to="/upload/voice_conversion" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload V2V Model
</Link>
</div>
<div className="col-12 col-lg-4">
<Link to="/upload/w2l_photo" className="btn btn-secondary">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload W2L Model
</Link>
</div>
</div>
</div>
</>
);
if (!props.sessionSubscriptionsWrapper.hasPaidFeatures()) {
upgradeButton = (
<>
<Link
to={WebUrl.pricingPageWithReferer("tts_hero_user")}
// onClick={() => {
// Analytics.ttsClickHeroUpgradePlan();
// }}
>
<button type="button" className="btn btn-primary w-100">
<FontAwesomeIcon icon={faStar} className="me-2" />
{t("heroButtonUpgradePlan")}
</button>
</Link>
</>
);
}
}
const randomHeroImage = useMemo(() => {
const images = [
// Main Images:
"mascot/kitsune_pose2.webp",
// "mascot/may4th.webp",
// "mascot/halloween_1.webp",
// "mascot/halloween_2.webp",
// "mascot/halloween_3.webp",
// "mascot/xmas_1.webp",
// "mascot/xmas_2.webp",
// "mascot/xmas_3.webp",
// "mascot/xmas_4.webp",
];
return images[Math.floor(Math.random() * images.length)];
}, []);
const isLoggedIn = props.sessionWrapper.isLoggedIn();
return (
<div>
<div className="container pb-5 pb-lg-0 pt-lg-4 px-md-5 px-lg-5 px-xl-3">
{/* Face Animation Alert Banner */}
<Alert
id="face-animation-alert"
icon={faSparkles}
message="Have you tried our new Face Animator? Turn photos of faces into animated lip-synced videos with just a picture and some audio!"
alertVariant="new"
link="/face-animator"
linkText="Try it now"
/>
<div className="row flex-md-row-reverse">
<div className="col-12 col-lg-5 p-md-0 d-flex justify-content-center">
<img
src={randomHeroImage}
alt="FakeYou Mascot"
height={440}
width={447}
className="img-fluid"
/>
</div>
<div className="col-12 col-lg-7 d-flex flex-column justify-content-center flex-reverse px-md-5 px-lg-3">
<h1 className="fw-bold display-5 text-center text-lg-start px-md-5 px-lg-0">
{t("heroTitle")}
</h1>
<p className="lead opacity-75 pb-4 text-center text-lg-start px-md-5 px-lg-0 pe-lg-5">
{t("heroText")}
</p>
<div className="d-flex flex-column flex-md-row gap-3 mt-3 mb-4 w-100 justify-content-center justify-content-lg-start">
{upgradeButton}
{signUpButton}
{viewPricingButton}
{myProfileButton}
</div>
</div>
</div>
</div>
{/* <div className="container-panel pb-5 mb-4">
<div className="panel p-3 py-4 p-md-4">
<form
className="w-100 d-flex flex-column"
// onSubmit={handleFormSubmit}
>
<div className="row gx-5 gy-4">
<div className="col-12 col-md-6 d-flex flex-column gap-4">
<div>
<label className="sub-title">
Pick a Voice (xxx to choose from)
</label>
<div className="input-icon-search pb-4">
<span className="form-control-feedback">
<FontAwesomeIcon icon={faMicrophone} />
</span>
<VcModelListSearch
voiceConversionModels={props.voiceConversionModels}
setVoiceConversionModels={props.setVoiceConversionModels}
maybeSelectedVoiceConversionModel={
props.maybeSelectedVoiceConversionModel
}
setMaybeSelectedVoiceConversionModel={interceptModelChange}
/>
</div>
</div>
<p className="mt-4">
<span className="opacity-75">
Pick one to get started, or choose from
</span>{" "}
<Link to="/tts">thousands more!</Link>
</p>
</div>
<div className="col-12 col-md-6 d-flex flex-column">
<label className="sub-title">Select Your Input</label>
<div className="d-flex flex-column flex-lg-row gap-3">
<button className="btn btn-primary w-100">
<FontAwesomeIcon icon={faRightLeft} className="me-2" />
Text
</button>
<button className="btn btn-primary w-100">
<FontAwesomeIcon icon={faFileArrowUp} className="me-2" />
Upload
</button>
<button className="btn btn-primary w-100">
<FontAwesomeIcon icon={faMicrophone} className="me-2" />
Microphone
</button>
</div>
</div>
</div>
</form>
</div>
</div> */}
<div className="container pb-4 px-md-5 px-xl-3">
<h2 className="fw-bold mb-4">{t("productsTitle")}</h2>
<div className="row g-4 position-relative">
<div className="col-12 col-md-4">
<Link
to="/tts"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<h3 className="fw-bold text-white mb-1">
{t("productTtsTitle")}
</h3>
<h6 className="fw-normal opacity-75 text-white">
{t("productTtsText")}
</h6>
</div>
<Link to="/tts" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-tts.webp"
alt="Text to speech"
<>
<Container type="panel">
{domain.title === "FakeYou" ? (
<>
{/* FAKEYOU.COM */}
{!isLoggedIn && (
<FakeYouLandingHeader
sessionWrapper={props.sessionWrapper}
sessionSubscriptionsWrapper={props.sessionSubscriptionsWrapper}
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/voice-conversion"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<h3 className="fw-bold text-white mb-1">
{t("productVcTitle")}
</h3>
<h6 className="fw-normal opacity-75 text-white">
{t("productVcText")}
</h6>
</div>
<Link to="/voice-conversion" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
)}
<img
className="img-fluid"
src="/images/landing/select-vc.webp"
alt="Voice Conversion"
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/voice-designer"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<div className="mb-1">
<span className="badge-beta d-inline-flex align-items-center mb-2 me-2">
<FontAwesomeIcon icon={faFlask} className="me-1" />
BETA
</span>
<Dashboard sessionWrapper={props.sessionWrapper} />
<h4 className="fw-bold text-white d-inline-flex align-items-center mb-0">
<span>Voice Designer</span>
</h4>
</div>
<h6 className="fw-normal opacity-75 text-white">
Create your own AI voice
</h6>
</div>
<Link to="/voice-designer" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-vd.webp"
alt="Voice Designer"
/>
</Link>
</div>
<div className="col-12 col-md-4">
<Link
to="/face-animator"
className="panel panel-select d-flex flex-column align-items-center"
>
<div className="d-flex px-4 pt-4 align-items-start w-100">
<div className="flex-grow-1">
<div className="mb-1">
<span className="badge-new d-inline-flex align-items-center mb-2 me-2">
<FontAwesomeIcon icon={faSparkles} className="me-1" />
{t("productNewTag")}
</span>
<h4 className="fw-bold text-white d-inline-flex align-items-center mb-0">
<span>{t("productFaceAnimatorTitle")}</span>
</h4>
</div>
<h6 className="fw-normal opacity-75 text-white">
{t("productFaceAnimatorText")}
</h6>
</div>
<Link to="/face-animator" className="btn btn-square mt-1">
<FontAwesomeIcon icon={faArrowRight} />
</Link>
</div>
<img
className="img-fluid"
src="/images/landing/select-w2l.webp"
alt="Face Animator"
/>
</Link>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-right-top"
/>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom"
/>
</div>
{uploadModelSection}
</div>
<div className="container section px-md-5 px-xl-3">
<div className="row g-4 g-lg-5 flex-row-reverse">
<div className="col-12 col-md-6 col-lg-7">
<div className="position-relative">
<div className="ratio ratio-16x9 video-container">
<video
autoPlay={true}
playsInline={true}
loop={true}
muted={true}
>
<source src="/videos/tts-video.mp4" type="video/mp4" />
</video>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-right-bottom"
/>
</div>
</div>
<div className="col-12 col-md-6 col-lg-5 d-flex flex-column justify-content-center gap-3">
<h2 className="fw-bold">{t("contextTitle")}</h2>
<p className="opacity-75">{t("contextText")}</p>
<div className="d-flex mt-3 gap-3">
<Link to="/tts" className="btn btn-primary">
{t("contextButtonTts")}
</Link>
<Link to="/voice-conversion" className="btn btn-primary">
{t("contextButtonVc")}
</Link>
</div>
</div>
</div>
</div>
{/* <div className="container section px-md-5 px-xl-3">
<div className="row g-4 g-lg-5">
<div className="col-12 col-md-6 col-lg-7">
<div className="position-relative">
<div className="ratio ratio-16x9 video-container position-relative">
<video
autoPlay={true}
playsInline={true}
loop={true}
muted={true}
>
<source src="/videos/tts-video.webm" type="video/webm" />
</video>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom"
/>
</div>
</div>
<div className="col-12 col-md-6 col-lg-5 d-flex flex-column justify-content-center gap-3">
<h2 className="fw-bold">Speak as your favorite characters</h2>
<p className="opacity-75">
Transform your speaking voice into someone else's voice with our
cutting-edge voice conversion service. Just upload or record your
audio and let our AI-powered technology do the rest. Perfect for
entertainment, voiceovers, and language learning materials.
</p>
<div className="d-flex mt-3">
<Link to="/voice-conversion" className="btn btn-primary">
Convert your voice
</Link>
</div>
</div>
</div>
</div> */}
<div className="d-flex flex-column section align-items-center panel rounded-0 my-5">
<div className="d-flex flex-column align-items-center text-center cta-container">
<div className="d-flex d-lg-none align-items-center mb-4 cta-mobile">
<img
src="/images/landing/hanashi-before.webp"
alt="hanashi before"
width={200}
height={200}
/>
<img
src="/images/landing/chevrons-red.webp"
alt="red chevrons"
width={95}
height={80}
/>
<img
src="/images/landing/hanashi-after.webp"
alt="hanashi after"
width={200}
height={200}
/>
</div>
<h2 className="fw-bold">{t("ctaTitle")}</h2>
<p className="mw-300 opacity-75">{t("ctaText")}</p>
<div className="d-flex gap-3 justify-content-center mt-4">
<Link to="/tts" className="btn btn-primary">
{t("ctaButtonTts")}
</Link>
<Link to="/voice-conversion" className="btn btn-primary">
{t("ctaButtonVc")}
</Link>
</div>
<img
src="/images/landing/hanashi-before.webp"
alt="hanashi before"
className="hanashi-before d-none d-lg-block"
width={311}
height={311}
/>
<img
src="/images/landing/chevrons-grey.webp"
alt="grey chevrons"
className="chevrons-grey d-none d-lg-block"
width={127}
height={108}
/>
<img
src="/images/landing/chevrons-red.webp"
alt="red chevrons"
className="chevrons-red d-none d-lg-block"
width={127}
height={108}
/>
<img
src="/images/landing/hanashi-after.webp"
alt="hanashi after"
className="hanashi-after d-none d-lg-block"
width={311}
height={311}
/>
</div>
</div>
<div className="py-4">
<div className="container text-center community-container">
<div className="panel px-4 py-5 d-flex flex-column align-items-center community-container rounded">
<h2 className="fw-bold mb-2">{t("communityTitle")}</h2>
<p className="opacity-75">{t("communityText")}</p>
<div className="d-flex mt-4 gap-3">
<a
href="https://discord.gg/fakeyou"
target="_blank"
rel="noreferrer"
className="btn btn-discord"
>
<FontAwesomeIcon icon={faDiscord} className="me-2" />
{t("communityButtonDiscord")}
</a>
<a
href="https://twitter.com/intent/follow?screen_name=FakeYouApp"
target="_blank"
rel="noreferrer"
className="btn btn-twitter"
>
<FontAwesomeIcon icon={faTwitter} className="me-2" />
{t("communityButtonTwitter")}
</a>
</div>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom me-3"
/>
</div>
</div>
</div>
{!isLoggedIn && <FakeYouLandingBody />}
</>
) : (
<>
{/* STORYTELLER,AI */}
{!isLoggedIn ? (
<StorytellerLanding />
) : (
<Dashboard sessionWrapper={props.sessionWrapper} />
)}
</>
)}
</Container>
</>
);
}
@@ -0,0 +1,88 @@
import { faDiscord, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Panel } from "components/common";
import { useLocalize } from "hooks";
import React from "react";
import { Link } from "react-router-dom";
interface FakeYouLandingBodyProps {}
export default function FakeYouLandingBody(props: FakeYouLandingBodyProps) {
const { t } = useLocalize("LandingPage");
return (
<>
<Panel clear={true}>
<div className="section px-md-5 px-xl-3">
<div className="row g-4 g-lg-5 flex-row-reverse">
<div className="col-12 col-md-6 col-lg-7">
<div className="position-relative">
<div className="ratio ratio-16x9 video-container">
<video
autoPlay={true}
playsInline={true}
loop={true}
muted={true}
>
<source src="/videos/tts-video.mp4" type="video/mp4" />
</video>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-right-bottom"
/>
</div>
</div>
<div className="col-12 col-md-6 col-lg-5 d-flex flex-column justify-content-center gap-3">
<h2 className="fw-bold">{t("contextTitle")}</h2>
<p className="opacity-75">{t("contextText")}</p>
<div className="d-flex mt-3 gap-3">
<Link to="/tts" className="btn btn-primary">
{t("contextButtonTts")}
</Link>
<Link to="/voice-conversion" className="btn btn-primary">
{t("contextButtonVc")}
</Link>
</div>
</div>
</div>
</div>
</Panel>
<div className="py-4 mb-4">
<div className="container text-center community-container">
<div className="panel px-4 py-5 d-flex flex-column align-items-center community-container rounded">
<h2 className="fw-bold mb-2">{t("communityTitle")}</h2>
<p className="opacity-75">{t("communityText")}</p>
<div className="d-flex mt-4 gap-3">
<a
href="https://discord.gg/fakeyou"
target="_blank"
rel="noreferrer"
className="btn btn-discord"
>
<FontAwesomeIcon icon={faDiscord} className="me-2" />
{t("communityButtonDiscord")}
</a>
<a
href="https://twitter.com/intent/follow?screen_name=FakeYouApp"
target="_blank"
rel="noreferrer"
className="btn btn-twitter"
>
<FontAwesomeIcon icon={faTwitter} className="me-2" />
{t("communityButtonTwitter")}
</a>
</div>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom me-3"
/>
</div>
</div>
</>
);
}
@@ -0,0 +1,152 @@
import { faArrowRight, faStar, faUser } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { WebUrl } from "common/WebUrl";
import { Panel } from "components/common";
import { useLocalize } from "hooks";
import React, { useMemo } from "react";
import { Link } from "react-router-dom";
interface FakeYouLandingHeaderProps {
sessionWrapper: SessionWrapper;
sessionSubscriptionsWrapper: SessionSubscriptionsWrapper;
}
export default function FakeYouLandingHeader(props: FakeYouLandingHeaderProps) {
const { t } = useLocalize("LandingPage");
const isLoggedIn = props.sessionWrapper.isLoggedIn();
const isSubscribed = props.sessionSubscriptionsWrapper.hasPaidFeatures();
let signUpButton = <></>;
let viewPricingButton = <></>;
let upgradeButton = <></>;
let myProfileButton = <></>;
if (!isLoggedIn) {
signUpButton = (
<>
<Link
to="/signup"
// onClick={() => {
// Analytics.ttsClickHeroSignup();
// }}
>
<button type="button" className="btn btn-primary w-100">
{t("heroButtonSignUp")}
<FontAwesomeIcon icon={faArrowRight} className="ms-2" />
</button>
</Link>
</>
);
viewPricingButton = (
<>
<Link
to={WebUrl.pricingPageWithReferer("tts_hero_new")}
// onClick={() => {
// Analytics.ttsClickHeroViewPricing();
// }}
>
<button type="button" className="btn btn-secondary w-100">
<FontAwesomeIcon icon={faStar} className="me-2" />
{t("heroButtonPricing")}
</button>
</Link>
</>
);
}
if (isLoggedIn) {
let displayName = props.sessionWrapper.getDisplayName() as string; // NB: If logged in, should be string
let url = WebUrl.userProfilePage(displayName);
myProfileButton = (
<>
<Link
to={url}
// onClick={() => {
// Analytics.ttsClickHeroViewProfile();
// }}
>
<button type="button" className="btn btn-secondary w-100">
<FontAwesomeIcon icon={faUser} className="me-2" />
{t("heroButtonProfile")}
</button>
</Link>
</>
);
if (!isSubscribed) {
upgradeButton = (
<>
<Link
to={WebUrl.pricingPageWithReferer("tts_hero_user")}
// onClick={() => {
// Analytics.ttsClickHeroUpgradePlan();
// }}
>
<button type="button" className="btn btn-primary w-100">
<FontAwesomeIcon icon={faStar} className="me-2" />
{t("heroButtonUpgradePlan")}
</button>
</Link>
</>
);
}
}
const randomHeroImage = useMemo(() => {
const images = [
// Main Images:
"mascot/kitsune_pose2.webp",
// "mascot/may4th.webp",
// "mascot/halloween_1.webp",
// "mascot/halloween_2.webp",
// "mascot/halloween_3.webp",
// "mascot/xmas_1.webp",
// "mascot/xmas_2.webp",
// "mascot/xmas_3.webp",
// "mascot/xmas_4.webp",
];
return images[Math.floor(Math.random() * images.length)];
}, []);
return (
<Panel clear={true} className="mt-5">
{/* <Alert
id="face-animation-alert"
icon={faSparkles}
message="Have you tried our new Face Animator? Turn photos of faces into animated lip-synced videos with just a picture and some audio!"
alertVariant="new"
link="/face-animator"
linkText="Try it now"
/> */}
<div className="row flex-md-row-reverse">
<div className="col-12 col-lg-5 p-md-0 d-flex justify-content-center">
<img
src={randomHeroImage}
alt="FakeYou Mascot"
height={440}
width={447}
className="img-fluid"
/>
</div>
<div className="col-12 col-lg-7 d-flex flex-column justify-content-center flex-reverse px-md-5 px-lg-3">
<h1 className="fw-bold display-5 text-center text-lg-start px-md-5 px-lg-0">
{t("heroTitle")}
</h1>
<p className="lead opacity-75 pb-4 text-center text-lg-start px-md-5 px-lg-0 pe-lg-5">
{t("heroText")}
</p>
<div className="d-flex flex-column flex-md-row gap-3 mt-3 mb-4 w-100 justify-content-center justify-content-lg-start">
{upgradeButton}
{signUpButton}
{viewPricingButton}
{myProfileButton}
</div>
</div>
</div>
</Panel>
);
}
@@ -0,0 +1,224 @@
import { faDiscord, faTwitter } from "@fortawesome/free-brands-svg-icons";
import {
faArrowRight,
faCompass,
faMessageDots,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Container, Input, Panel, Select } from "components/common";
import { useLocalize } from "hooks";
import React from "react";
import { Link } from "react-router-dom";
interface StorytellerLandingProps {}
export default function StorytellerLanding(props: StorytellerLandingProps) {
const { t } = useLocalize("LandingPage");
return (
<>
<Container type="panel" className="py-5">
<Panel clear={true} className="py-lg-5">
<div className="row g-5 g-lg-5 flex-row-reverse">
<div className="col-12 col-md-6">
<div className="position-relative">
<div className="ratio ratio-16x9 video-container">
<video
autoPlay={true}
playsInline={true}
loop={true}
muted={true}
>
<source src="/videos/tts-video.mp4" type="video/mp4" />
</video>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-right-bottom"
/>
</div>
</div>
<div className="col-12 col-md-6 d-flex flex-column justify-content-center text-center text-lg-start gap-2">
<h1 className="fw-bold display-5">
Enabling Anyone to Create Quality Movies with AI
</h1>
<p className="opacity-75">
We are combining generative AI and User Generated Content to
radically democratize both audio and video production.
</p>
<div className="d-flex mt-3 mt-lg-4 gap-3 justify-content-center justify-content-lg-start">
<Button
icon={faArrowRight}
iconFlip={true}
label="Sign Up"
to="/signup"
/>
<Button
icon={faCompass}
label="Explore"
variant="secondary"
to="/explore"
/>
</div>
</div>
</div>
</Panel>
</Container>
<div className="panel rounded-0 py-5 my-5">
<Container type="panel" className="py-lg-5">
<Panel clear={true}>
<div className="d-flex flex-column align-items-center rounded-0 mt-lg-4 pt-lg-4">
<div className="d-flex flex-column align-items-center text-center cta-container">
<h2 className="fw-bold">AI Audio Generation</h2>
<p className="mw-300 opacity-75">{t("ctaText")}</p>
<div className="d-flex d-lg-none align-items-center mt-2 cta-mobile">
<img
src="/images/landing/hanashi-before.webp"
alt="hanashi before"
width={200}
height={200}
/>
<img
src="/images/landing/chevrons-red.webp"
alt="red chevrons"
width={95}
height={80}
/>
<img
src="/images/landing/hanashi-after.webp"
alt="hanashi after"
width={200}
height={200}
/>
</div>
<img
src="/images/landing/hanashi-before.webp"
alt="hanashi before"
className="hanashi-before d-none d-lg-block"
width={311}
height={311}
/>
<img
src="/images/landing/chevrons-grey.webp"
alt="grey chevrons"
className="chevrons-grey d-none d-lg-block"
width={127}
height={108}
/>
<img
src="/images/landing/chevrons-red.webp"
alt="red chevrons"
className="chevrons-red d-none d-lg-block"
width={127}
height={108}
/>
<img
src="/images/landing/hanashi-after.webp"
alt="hanashi after"
className="hanashi-after d-none d-lg-block"
width={311}
height={311}
/>
</div>
</div>
<div className="row gy-4 mt-lg-3">
<div className="col-12 col-md-6">
<Panel padding={true} className="panel-inner rounded h-100">
<div className="d-flex gap-2">
<h4 className="fw-semibold mb-4 flex-grow-1">
<FontAwesomeIcon icon={faMessageDots} className="me-3" />
Text to Speech
</h4>
<Link to="/tts">
Try more voices{" "}
<FontAwesomeIcon icon={faArrowRight} className="ms-1" />
</Link>
</div>
<div className="d-flex flex-column gap-3">
<Select label="Select a Voice" />
<Input
label="Your Text"
placeholder="Type what you want your character to say"
/>
<div className="d-flex gap-2 justify-content-end">
<Button
label="Generate"
icon={faArrowRight}
iconFlip={true}
/>
</div>
</div>
</Panel>
</div>
<div className="col-12 col-md-6">
<Panel padding={true} className="panel-inner rounded h-100">
<div className="d-flex gap-2">
<h4 className="fw-semibold mb-4 flex-grow-1">
<FontAwesomeIcon
icon={faWaveformLines}
className="me-3"
/>
Voice to Voice
</h4>
<Link to="/voice-conversion">
Try more voices{" "}
<FontAwesomeIcon icon={faArrowRight} className="ms-1" />
</Link>
</div>
<div className="d-flex flex-column gap-3">
<Select label="Select a Voice" />
<div className="d-flex gap-2 justify-content-end">
<Button
label="Generate"
icon={faArrowRight}
iconFlip={true}
/>
</div>
</div>
</Panel>
</div>
</div>
</Panel>
</Container>
</div>
<Container type="panel" className="pt-5 my-5">
<Panel padding={true}>
<div className="d-flex flex-column align-items-center py-5">
<h2 className="fw-bold mb-2">{t("communityTitle")}</h2>
<p className="opacity-75">{t("communityText")}</p>
<div className="d-flex mt-4 gap-3">
<a
href="https://discord.gg/fakeyou"
target="_blank"
rel="noreferrer"
className="btn btn-discord"
>
<FontAwesomeIcon icon={faDiscord} className="me-2" />
{t("communityButtonDiscord")}
</a>
<a
href="https://twitter.com/intent/follow?screen_name=FakeYouApp"
target="_blank"
rel="noreferrer"
className="btn btn-twitter"
>
<FontAwesomeIcon icon={faTwitter} className="me-2" />
{t("communityButtonTwitter")}
</a>
</div>
</div>
<img
src="/images/landing/bg-dots.webp"
alt="background dots"
className="dots-left-bottom me-3"
/>
</Panel>
</Container>
</>
);
}
@@ -14,7 +14,7 @@ import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocument
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import Container from "components/common/Container";
import Panel from "components/common/Panel";
import PageHeader from "components/layout/PageHeader";
import { useDomainConfig } from "context/DomainConfigContext";
interface Props {
sessionWrapper: SessionWrapper;
@@ -25,7 +25,7 @@ interface Props {
function LoginPage(props: Props) {
let history = useHistory();
PosthogClient.recordPageview();
const domain = useDomainConfig();
const [password, setPassword] = useState("");
const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [errorMessage, setErrorMessage] = useState("");
@@ -90,12 +90,12 @@ function LoginPage(props: Props) {
}
return (
<Container type="panel" className="login-panel">
<PageHeader
title="Login"
subText="Log into your account."
panel={false}
/>
<Container
type="panel"
className="login-panel d-flex flex-column align-items-center"
>
<h2 className="fw-bold mb-0 mt-5 mb-4">Login to {domain.title}</h2>
<Panel padding={true}>
{errorWarning}
@@ -132,10 +132,16 @@ function LoginPage(props: Props) {
onChange={handlePasswordChange}
/>
</div>
<p className="d-flex flex-column flex-lg-row gap-2">
<Link to="/password-reset" className="text-link form-text">
<p className="d-flex flex-lg-row gap-2">
<Link
to="/password-reset"
className="text-link form-text flex-grow-1"
>
Forgot your password?
</Link>
<span className="form-text text-link">
Don't have an account? <Link to="/signup">Sign up</Link>
</span>
</p>
</div>
</div>
@@ -17,6 +17,7 @@ import { WebUrl } from "../../../../common/WebUrl";
import { BeginStripeCheckoutFlow } from "../../../../common/BeginStripeCheckoutFlow";
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import { Container, Panel } from "components/common";
interface Props {
sessionWrapper: SessionWrapper;
@@ -147,8 +148,8 @@ function PricingPage(props: Props) {
}
return (
<div>
<div className="container pt-5 pb-4 text-center">
<Container type="panel">
<Panel clear={true} className="text-center my-5">
<h1 className=" fw-bold">Pricing</h1>
{/* <p className="fs-5">
By purchasing FakeYou premium, you help us build more!
@@ -157,8 +158,9 @@ function PricingPage(props: Props) {
<FontAwesomeIcon icon={faHeart} className="text-red me-3" />
By purchasing FakeYou premium, you help us build more!
</div>
</div>
<div className="container mb-5">
</Panel>
<Panel clear={true}>
<div className="row gx-3 gy-4">
{/* Starter Tier */}
{/*<div className="col-12 col-sm-6 col-lg-3" >
@@ -712,8 +714,8 @@ function PricingPage(props: Props) {
</div>
</div>
</div> */}
</div>
</div>
</Panel>
</Container>
);
}
@@ -18,8 +18,8 @@ import { BeginStripeCheckoutFlow } from "../../../../common/BeginStripeCheckoutF
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import { PosthogClient } from "@storyteller/components/src/analytics/PosthogClient";
import Container from "components/common/Container";
import PageHeader from "components/layout/PageHeader";
import Panel from "components/common/Panel";
import { useDomainConfig } from "context/DomainConfigContext";
enum FieldTriState {
EMPTY_FALSE,
@@ -34,6 +34,7 @@ interface Props {
function SignupPage(props: Props) {
let history = useHistory();
const domain = useDomainConfig();
const parsedQueryString = queryString.parse(window.location.search);
@@ -349,12 +350,11 @@ function SignupPage(props: Props) {
}
return (
<Container type="panel" className="login-panel">
<PageHeader
title="Sign Up"
subText="Create a new account."
panel={false}
/>
<Container
type="panel"
className="login-panel d-flex flex-column align-items-center"
>
<h2 className="fw-bold mb-0 mt-5 mb-4">Sign Up for {domain.title}</h2>
<Panel padding={true}>
<form onSubmit={handleFormSubmit}>