diff --git a/packages/components/src/api/ApiConfig.ts b/packages/components/src/api/ApiConfig.ts index ca754d2924..ada9b5f4e8 100644 --- a/packages/components/src/api/ApiConfig.ts +++ b/packages/components/src/api/ApiConfig.ts @@ -95,6 +95,9 @@ class ApiConfig { } } + //v2ApiHost = "api.dev.fakeyou.com:12345"; + //useSsl = false; + this.domain = domain; this.useSsl = useSsl; this.v2ApiHost = v2ApiHost; diff --git a/packages/fakeyou.com/public/mascot/trump.webp b/packages/fakeyou.com/public/mascot/trump.webp new file mode 100644 index 0000000000..f81a90ab7c Binary files /dev/null and b/packages/fakeyou.com/public/mascot/trump.webp differ diff --git a/packages/fakeyou.com/src/v2/view/PageContainer.tsx b/packages/fakeyou.com/src/v2/view/PageContainer.tsx index 5b3e46bf49..b426004016 100644 --- a/packages/fakeyou.com/src/v2/view/PageContainer.tsx +++ b/packages/fakeyou.com/src/v2/view/PageContainer.tsx @@ -66,6 +66,7 @@ import { GetComputedTtsCategoryAssignmentsSuccessResponse } from "@storyteller/c import { NewsPage } from "./pages/news/NewsPage"; import { LandingPage } from "./pages/landing/LandingPage"; import { ChannelsPage } from "./pages/channels/Channels"; +import { TrumpTtsPage } from "./pages/character/trump/TrumpTtsPage"; //import { LandingPage } from "./pages/landing/LandingPage"; //import { VcModelListPage } from "./pages/vc/vc_model_list/VcModelListPage"; @@ -479,6 +480,48 @@ class PageContainer extends React.Component { + + + + void; + ttsInferenceJobs: Array; + ttsModelUploadJobs: Array; + w2lInferenceJobs: Array; + w2lTemplateUploadJobs: Array; + textBuffer: string; + setTextBuffer: (textBuffer: string) => void; + clearTextBuffer: () => void; + + ttsModels: Array; + setTtsModels: (ttsVoices: Array) => void; + + allTtsCategories: TtsCategoryType[]; + setAllTtsCategories: (allTtsCategories: TtsCategoryType[]) => void; + + computedTtsCategoryAssignments?: GetComputedTtsCategoryAssignmentsSuccessResponse; + setComputedTtsCategoryAssignments: ( + categoryAssignments: GetComputedTtsCategoryAssignmentsSuccessResponse + ) => void; + + allTtsCategoriesByTokenMap: Map; + allTtsModelsByTokenMap: Map; + ttsModelsByCategoryToken: Map>; + + dropdownCategories: TtsCategoryType[][]; + setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void; + selectedCategories: TtsCategoryType[]; + setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void; + + maybeSelectedTtsModel?: TtsModelListItem; + setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void; + + selectedTtsLanguageScope: string; + setSelectedTtsLanguageScope: (selectedTtsLanguageScope: string) => void; +} + +function TrumpTtsPage(props: Props) { + //Loading spinning icon + const [loading, setLoading] = useState(false); + const [isAudioLimitAlertVisible, setAudioLimitAlertVisible] = useState(false); + + const handleLoading = () => { + setLoading(true); + setTimeout(() => { + setLoading(false); + }, 2000); + }; + + useEffect(() => { + const timeout = setTimeout(() => { + setLoading(false); + }, 2000); + return () => clearTimeout(timeout); + }, []); + + let { + setTtsModels, + setAllTtsCategories, + setComputedTtsCategoryAssignments, + ttsModels, + allTtsCategories, + computedTtsCategoryAssignments, + maybeSelectedTtsModel, + setMaybeSelectedTtsModel, + } = props; + + const [maybeTtsError, setMaybeTtsError] = useState< + GenerateTtsAudioErrorType | undefined + >(undefined); + + const ttsModelsLoaded = ttsModels.length > 0; + const ttsCategoriesLoaded = allTtsCategories.length > 0; + const computedTtsCategoryAssignmentsLoaded = + computedTtsCategoryAssignments !== undefined && + computedTtsCategoryAssignments.category_token_to_tts_model_tokens.recursive + .size > 0; + + const listModels = useCallback(async () => { + if (ttsModelsLoaded) { + return; // Already queried. + } + const models = await ListTtsModels(); + if (models) { + DynamicallyCategorizeModels(models); + setTtsModels(models); + if (!maybeSelectedTtsModel && models.length > 0) { + let model = models[0]; + const featuredModels = models.filter((m) => m.is_front_page_featured); + if (featuredModels.length > 0) { + // Random featured model + model = + featuredModels[Math.floor(Math.random() * featuredModels.length)]; + } + setMaybeSelectedTtsModel(model); + } + } + }, [ + setTtsModels, + maybeSelectedTtsModel, + setMaybeSelectedTtsModel, + ttsModelsLoaded, + ]); + + const listTtsCategories = useCallback(async () => { + if (ttsCategoriesLoaded) { + return; // Already queried. + } + const categoryList = await ListTtsCategories(); + if (ListTtsCategoriesIsOk(categoryList)) { + let categories = categoryList.categories; + + // NB: We'll use the frontend to order the synthetic categories first. + + const LATEST_MODELS_CATEGORY_TOKEN = "SYNTHETIC_CATEGORY:LATEST_MODELS"; + const TRENDING_MODELS_CATEGORY_TOKEN = + "SYNTHETIC_CATEGORY:TRENDING_MODELS"; + + let maybeLatestCategory = categories.find( + (category) => category.category_token === LATEST_MODELS_CATEGORY_TOKEN + ); + + let maybeTrendingCategory = categories.find( + (category) => category.category_token === TRENDING_MODELS_CATEGORY_TOKEN + ); + + let otherCategories = categories + .filter( + (category) => category.category_token !== LATEST_MODELS_CATEGORY_TOKEN + ) + .filter( + (category) => + category.category_token !== TRENDING_MODELS_CATEGORY_TOKEN + ); + + categories = []; + + if (maybeLatestCategory !== undefined) { + categories.push(maybeLatestCategory); + } + + if (maybeTrendingCategory !== undefined) { + categories.push(maybeTrendingCategory); + } + + categories.push(...otherCategories); + + setAllTtsCategories(categories); + } else if (ListTtsCategoriesIsError(categoryList)) { + // TODO: Retry on decay function + } + }, [setAllTtsCategories, ttsCategoriesLoaded]); + + const getComputedAssignments = useCallback(async () => { + if (computedTtsCategoryAssignmentsLoaded) { + return; // Already queried. + } + const computedAssignments = await GetComputedTtsCategoryAssignments(); + if (GetComputedTtsCategoryAssignmentsIsOk(computedAssignments)) { + setComputedTtsCategoryAssignments(computedAssignments); + } else if (GetComputedTtsCategoryAssignmentsIsError(computedAssignments)) { + // TODO: Retry on decay function + } + }, [setComputedTtsCategoryAssignments, computedTtsCategoryAssignmentsLoaded]); + + useEffect(() => { + listModels(); + listTtsCategories(); + getComputedAssignments(); + }, [listModels, listTtsCategories, getComputedAssignments]); + + const handleChangeText = (ev: React.FormEvent) => { + const textValue = (ev.target as HTMLTextAreaElement).value; + props.setTextBuffer(textValue); + setAudioLimitAlertVisible(textValue.length > 100); + }; + + const handleFormSubmit = async (ev: React.FormEvent) => { + ev.preventDefault(); + + if (!props.maybeSelectedTtsModel) { + return false; + } + + if (!props.textBuffer) { + return false; + } + + const modelToken = props.maybeSelectedTtsModel!.model_token; + + const request = { + uuid_idempotency_token: uuidv4(), + tts_model_token: modelToken, + inference_text: props.textBuffer, + }; + + const response = await GenerateTtsAudio(request); + + Analytics.ttsGenerate(modelToken, props.textBuffer.length); + + if (GenerateTtsAudioIsOk(response)) { + setMaybeTtsError(undefined); + props.enqueueTtsJob(response.inference_job_token); + } else if (GenerateTtsAudioIsError(response)) { + setMaybeTtsError(response.error); + } + + return false; + }; + + const handleClearClick = (ev: React.FormEvent) => { + ev.preventDefault(); + props.clearTextBuffer(); + + Analytics.ttsClear(props.maybeSelectedTtsModel?.model_token); + + return false; + }; + + usePrefixedDocumentTitle(undefined); // NB: Sets to default title + + // Show errors on TTS failure + let maybeError = <>; + if (!!maybeTtsError) { + let hasMessage = false; + let message = <>; + switch (maybeTtsError) { + case GenerateTtsAudioErrorType.TooManyRequests: + hasMessage = true; + message = ( + + You're sending too many requests! + Slow down a little. We have to slow things down a little when the + server gets busy. + + ); + break; + case GenerateTtsAudioErrorType.ServerError | + GenerateTtsAudioErrorType.BadRequest | + GenerateTtsAudioErrorType.NotFound: + break; + } + + if (hasMessage) { + maybeError = ( +
+ + {message} +
+ ); + } + } + + // NB: If the text is too long, don't allow submission + let remainingCharactersButtonDisabled = props.textBuffer.trim().length > 1024; + let noTextInputButtonDisabled = props.textBuffer.trim() === ""; + + const speakButtonClass = loading + ? "btn btn-primary w-100 disabled" + : "btn btn-primary w-100"; + + let audioLimitAlert = <>; + if ( + isAudioLimitAlertVisible && + !props.sessionSubscriptionsWrapper.hasPaidFeatures() + ) { + audioLimitAlert = ( + <> +
+ + Note: Non-premium is limited to 12 seconds of audio.{" "} + + Upgrade now + + . + +
+ + ); + } + + const TOKENS = new Set([ + "TM:pmd1wm3kf6az", // Development: "Fake Donald Trump #1" + "TM:7rrwdhdq8ezq", // Development: "Fake Donald Trump #2" + //"TM:aejrk66wq3ss", // Production: "Donald Trump (bibby's model)" + "TM:pyzss4phqk6r", // Production: "Donald Trump (Sarcastic)" + "TM:4v0ft4j72y2g", // Production: "Donald Trump (Angry)" + "TM:03690khwpsbz", // Production: "Donald Trump (Casual Speech)" + ]); + + let trumpModels = props.ttsModels.filter((model) => { + return TOKENS.has(model.model_token); + }) + + let maybeSelectedModel = props.maybeSelectedTtsModel; + + if (maybeSelectedModel !== undefined && !TOKENS.has(maybeSelectedModel.model_token)) { + // If we don't select a trump model by default (which is rare), the list will be empty. + // We'll set it here. + maybeSelectedModel = trumpModels[0]; + } + + return ( + + + + +
+ + +
+
+ {/* Explore Rollout */} + + {/* + + + EXPLORE OMNIBAR GOES HERE TODO + + */} + +
+
+
+
+ +
+ + {audioLimitAlert} +
+ + +
+
+
+
+
+

+ + {t("tts.TtsModelListPage.sessionTtsResultsLabel")} +

+
+ +
+
+
+
+ {maybeError} + +
+
+
+
+ ); +} + +const LoadingIcon: React.FC = () => { + return ( + <> + + Loading... + + ); +}; + +export { TrumpTtsPage }; diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/TrumpTtsPageHero.tsx b/packages/fakeyou.com/src/v2/view/pages/character/trump/TrumpTtsPageHero.tsx new file mode 100644 index 0000000000..95b06e87c1 --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/TrumpTtsPageHero.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from "react"; +import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper"; +import { SessionSubscriptionsWrapper } from "@storyteller/components/src/session/SessionSubscriptionsWrapper"; +import { Link } from "react-router-dom"; +import { t } from "i18next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faStar, + faArrowRight, + faVolume, +} from "@fortawesome/pro-solid-svg-icons"; +import { Analytics } from "../../../../../common/Analytics"; +import { WebUrl } from "../../../../../common/WebUrl"; +import { PageHeaderWithImage } from "../../../_common/PageHeaderWithImage"; + +interface Props { + sessionWrapper: SessionWrapper; + sessionSubscriptionsWrapper: SessionSubscriptionsWrapper; +} + +export function TtsPageHero(props: Props) { + const randomImage = useMemo(() => { + const images = ["/mascot/trump.webp"]; + + return images[Math.floor(Math.random() * images.length)]; + }, []); + + let signUpButton = <>; + let viewPricingButton = <>; + + signUpButton = ( + <> + + + + + ); + viewPricingButton = ( + <> + { + Analytics.ttsClickHeroViewPricing(); + }} + > + + + + ); + + const titleIcon = ; + const title = <>Donald Trump TTS; + const subText = ( + <>FakeYou has the very best Donald Trump AI voice on the internet. Use deep fake Donald Trump to say your favorite memes. + ); + const actionButtons = ( + <> + {signUpButton} + {viewPricingButton} + + ); + + return ( + <> + + + ); +} diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/search/SearchOmnibar.tsx b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/SearchOmnibar.tsx new file mode 100644 index 0000000000..a56b86517b --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/SearchOmnibar.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { TtsCategoryType } from "../../../../../../AppWrapper"; +import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels"; +import { ScopedVoiceModelOptions } from "./components/ScopedVoiceModelOptions"; +import { VoiceCountLabel } from "./components/VoiceCountLabel"; + +interface Props { + allTtsCategories: TtsCategoryType[]; + allTtsModels: TtsModelListItem[]; + + allTtsCategoriesByTokenMap: Map; + allTtsModelsByTokenMap: Map; + ttsModelsByCategoryToken: Map>; + + dropdownCategories: TtsCategoryType[][]; + setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void; + + selectedCategories: TtsCategoryType[]; + setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void; + + maybeSelectedTtsModel?: TtsModelListItem; + setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void; + + selectedTtsLanguageScope: string; + setSelectedTtsLanguageScope: (selectedTtsLanguageScope: string) => void; +} + +export function SearchOmnibar(props: Props) { + return ( + <> +
+ + +
+
+ +
+
+
+ + ); +} diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/ScopedVoiceModelOptions.tsx b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/ScopedVoiceModelOptions.tsx new file mode 100644 index 0000000000..83e92181d4 --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/ScopedVoiceModelOptions.tsx @@ -0,0 +1,209 @@ +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels"; +import { TtsCategoryType } from "../../../../../../../AppWrapper"; +import Select, { createFilter } from "react-select"; +import { SearchFieldClass } from "./SearchFieldClass"; +import { FastReactSelectOption } from "../../../../../_common/react_select/FastReactSelectOption"; +import { Analytics } from "../../../../../../../common/Analytics"; +import { FixedSingleValueSelectOption } from "../../../../../_common/react_select/FixedSingleValueSelectOption"; +import { faMicrophone } from "@fortawesome/pro-duotone-svg-icons"; + +interface Props { + allTtsCategories: TtsCategoryType[]; + allTtsModels: TtsModelListItem[]; + + allTtsCategoriesByTokenMap: Map; + allTtsModelsByTokenMap: Map; + ttsModelsByCategoryToken: Map>; + + dropdownCategories: TtsCategoryType[][]; + selectedCategories: TtsCategoryType[]; + + maybeSelectedTtsModel?: TtsModelListItem; + setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void; + + selectedTtsLanguageScope: string; +} + +export function ScopedVoiceModelOptions(props: Props) { + const { + allTtsModels, + ttsModelsByCategoryToken, + selectedCategories, + maybeSelectedTtsModel, + } = props; + + //const { t } = useTranslation(); + + const handleChange = (option: any, actionMeta: any) => { + const ttsModelToken = option?.value; + const maybeNewTtsModel = props.allTtsModelsByTokenMap.get(ttsModelToken); + if (maybeNewTtsModel !== undefined) { + props.setMaybeSelectedTtsModel(maybeNewTtsModel); + } + }; + + const leafiestCategory = selectedCategories[selectedCategories.length - 1]; + + let leafiestCategoryModels: Array = []; + + if (leafiestCategory !== undefined) { + leafiestCategoryModels = Array.from( + ttsModelsByCategoryToken.get(leafiestCategory.category_token) || new Set() + ); + } else { + leafiestCategoryModels = Array.from(new Set(allTtsModels)); + } + + interface DropdownOption { + label: string; + value: string; + creatorName?: string; + } + + let options: DropdownOption[] = leafiestCategoryModels + .filter((ttsModel) => { + // Scope to currently selected language + if (props.selectedTtsLanguageScope === "*") { + return true; // NB: Sentinel value of "*" means all languages. + } + return ( + ttsModel.ietf_primary_language_subtag === props.selectedTtsLanguageScope + ); + }) + .map((ttsModel) => { + return { + label: ttsModel.title, + value: ttsModel.model_token, + creatorName: ttsModel.creator_display_name, + }; + }); + + let selectedOption = options.find( + (option) => option.value === maybeSelectedTtsModel?.model_token + ); + + if (selectedOption === undefined && options.length > 0) { + // NB: We shouldn't select the first item in the list since that won't update the currently + // selected model. If the user were to close the dialogue, they'd think they had picked a voice, + // when in reality no state would have changed. By forcing the user to choose, the user will set + // the state appropriately. + selectedOption = { + label: "Select voice...", + value: "*", + creatorName: undefined, + }; + } + + let isLoading = false; + + if (props.allTtsModels.length === 0) { + // NB: react-select will cache values, even across different instances (!!!) + // This can cause confusion when initializing a select instance before the data + // is loaded, and the select will never update to show the new data. + // The proper way to change voices after load from a placeholder "Loading..." + // label is to use controlled props / value as is done here: + isLoading = true; + selectedOption = { + label: "Loading...", + value: "*", + creatorName: undefined, + }; + } else if (options.length === 0) { + // NB: Perhaps the user has refined their search to be too narrow (langauge + category) + selectedOption = { + label: "No results (remove some filters)", + value: "*", + creatorName: undefined, + }; + } + + const numberVoices = options.length; + + const canSearchVoices = + numberVoices > 100 || // Always allow search when there are over 100 voices + window.innerWidth >= 1000; // Always allow search when on desktop + + let select; + + // Function to build the options themselves, so we can introduce extra elements. + const formatOptionLabel = ( + data: DropdownOption, + formatOptionLabelMeta: any + ) => { + let creatorName = <>; + if (data.creatorName !== undefined) { + creatorName = — {data.creatorName}; + } + return ( +
+ {data.label} + {creatorName} +
+ ); + }; + + // TODO: Cleanup + + if (canSearchVoices) { + select = ( + { + Analytics.ttsOpenScopedVoiceSelectMenu(); + }} + isLoading={isLoading} + // On mobile, we don't want the onscreen keyboard to take up half the UI. + autoFocus={false} + isSearchable={false} + // NB: The following settings improve upon performance. + // See: https://github.com/JedWatson/react-select/issues/3128 + filterOption={createFilter({ ignoreAccents: false })} + components={{ Option: FastReactSelectOption } as any} + formatOptionLabel={formatOptionLabel} + /> + ); + } + + return ( + <> +
+
+ + + + + {select} +
+
+ + ); +} diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/SearchFieldClass.ts b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/SearchFieldClass.ts new file mode 100644 index 0000000000..5c7ca2b92c --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/SearchFieldClass.ts @@ -0,0 +1,14 @@ +export const SearchFieldClass = { + control: (state: any) => + state.isFocused ? "select-search focused" : "select-search", + option: (state: any) => (state.isFocused ? "select-option" : "select-option"), + input: (state: any) => (state.isFocused ? "select-input" : "select-input"), + placeholder: (state: any) => + state.isFocused ? "select-placeholder" : "select-placeholder", + singleValue: (state: any) => + state.isFocused ? "select-value" : "select-value", + menu: (state: any) => + state.isFocused ? "select-container" : "select-container", + indicatorSeparator: (state: any) => + state.isFocused ? "select-separator" : "select-separator", +}; diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/VoiceCountLabel.tsx b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/VoiceCountLabel.tsx new file mode 100644 index 0000000000..7780d70480 --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/components/VoiceCountLabel.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels"; +import { TtsCategoryType } from "../../../../../../../AppWrapper"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faShuffle } from "@fortawesome/pro-solid-svg-icons"; +import { Analytics } from "../../../../../../../common/Analytics"; +import { GetRandomArrayValue } from "@storyteller/components/src/utils/GetRandomArrayValue"; +import Tippy from "@tippyjs/react"; +import "tippy.js/dist/tippy.css"; + +interface Props { + allTtsModels: TtsModelListItem[]; + ttsModelsByCategoryToken: Map>; + selectedCategories: TtsCategoryType[]; + selectedTtsLanguageScope: string; + setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void; +} + +// NB/TODO: This duplicates the work of a sister component, but it was the fastest way +// to hack this in without passing callbacks around or moving calculation higher up the +// component tree. + +export function VoiceCountLabel(props: Props) { + const { allTtsModels, ttsModelsByCategoryToken, selectedCategories } = props; + + const leafiestCategory = selectedCategories[selectedCategories.length - 1]; + + let leafiestCategoryModels: Array = []; + + if (leafiestCategory !== undefined) { + leafiestCategoryModels = Array.from( + ttsModelsByCategoryToken.get(leafiestCategory.category_token) || new Set() + ); + } else { + leafiestCategoryModels = Array.from(new Set(allTtsModels)); + } + + let possibleVoices = leafiestCategoryModels.filter((ttsModel) => { + // Scope to currently selected language + if (props.selectedTtsLanguageScope === "*") { + return true; // NB: Sentinel value of "*" means all languages. + } + return ( + ttsModel.ietf_primary_language_subtag === props.selectedTtsLanguageScope + ); + }); + + const selectRandomVoice = () => { + let randomVoice = undefined; + for (let i = 0; i < 10; i++) { + // We're going to try to find a *good* voice. + randomVoice = GetRandomArrayValue(possibleVoices); + if ( + randomVoice.user_ratings.positive_count > + randomVoice.user_ratings.negative_count + ) { + break; // First "mostly good" voice is good. + } + if (i % 3 === 2 && randomVoice.user_ratings.total_count === 0) { + break; // Sometimes we'll return a voice that hasn't been rated. + } + } + if (randomVoice !== undefined) { + props.setMaybeSelectedTtsModel(randomVoice); + } + }; + + const voiceCount = possibleVoices.length; + + return ( + <> +
+ +
+ + ); +} diff --git a/packages/fakeyou.com/src/v2/view/pages/character/trump/search/filters/CategoryOptions.tsx b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/filters/CategoryOptions.tsx new file mode 100644 index 0000000000..98b91193ed --- /dev/null +++ b/packages/fakeyou.com/src/v2/view/pages/character/trump/search/filters/CategoryOptions.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowRightLong, faTags } from "@fortawesome/free-solid-svg-icons"; +import { TtsModelListItem } from "@storyteller/components/src/api/tts/ListTtsModels"; +import { TtsCategoryType } from "../../../../../../../AppWrapper"; +//import { useTranslation } from "react-i18next"; +import Select from "react-select"; +import { SearchFieldClass } from "../components/SearchFieldClass"; +import { Analytics } from "../../../../../../../common/Analytics"; +import { t } from "i18next"; + +interface Props { + allTtsCategories: TtsCategoryType[]; + allTtsModels: TtsModelListItem[]; + + allTtsCategoriesByTokenMap: Map; + allTtsModelsByTokenMap: Map; + ttsModelsByCategoryToken: Map>; + + dropdownCategories: TtsCategoryType[][]; + setDropdownCategories: (dropdownCategories: TtsCategoryType[][]) => void; + + selectedCategories: TtsCategoryType[]; + setSelectedCategories: (selectedCategories: TtsCategoryType[]) => void; + + maybeSelectedTtsModel?: TtsModelListItem; + setMaybeSelectedTtsModel: (maybeSelectedTtsModel: TtsModelListItem) => void; + + handleChangeCategory: (level: number, maybeToken?: string) => void; + + selectedTtsLanguageScope: string; +} + +export function CategoryOptions(props: Props) { + const { + ttsModelsByCategoryToken, + dropdownCategories, + selectedCategories, + handleChangeCategory, + } = props; + + //const { t } = useTranslation(); + + let categoryDropdowns = buildDropdowns( + dropdownCategories, + selectedCategories, + ttsModelsByCategoryToken, + handleChangeCategory + ); + + const CATEGORY_SEPARATOR = ( +
+ +
+ ); + + let categoryDropdownsWithSeparators = []; + for (let i = 0; i < categoryDropdowns.length; i++) { + categoryDropdownsWithSeparators.push(categoryDropdowns[i]); + if (i < categoryDropdowns.length - 1) { + categoryDropdownsWithSeparators.push(CATEGORY_SEPARATOR); + } + } + + return ( + <> +
+ {categoryDropdownsWithSeparators} +
+ + ); +} + +// ========= Build dropdowns ======== + +function buildDropdowns( + dropdownCategories: TtsCategoryType[][], + selectedCategories: TtsCategoryType[], + ttsModelsByCategoryToken: Map>, + handleChangeCategory: (i: number, categoryToken?: string) => void +) { + if (dropdownCategories.length === 0 || ttsModelsByCategoryToken.size === 0) { + // While the XHR requests are still completing, we may have nothing to build. + // It's easier to return a fully disabled "loading" + , + ]; + } + + let categoryDropdowns = []; + + for (let i = 0; i < dropdownCategories.length; i++) { + const currentDropdownCategories = dropdownCategories[i]; + const selectedCategory = selectedCategories[i]; + + let defaultName = i === 0 ? "All Voices" : "Select..."; + + let dropdownOptions = []; + dropdownOptions.push( + + ); + + // TODO(bt, 2023-01-18): Clean this up + + // Transform into "react-select" library compatible options + const options = currentDropdownCategories + .filter((category) => { + // If there are no models at the leaves, skip the category + const models = ttsModelsByCategoryToken.get(category.category_token); + return !(models === undefined || models.size === 0); + }) + .map((category) => { + return { + value: category.category_token, + label: category.name_for_dropdown, + }; + }); + + currentDropdownCategories.forEach((category) => { + const models = ttsModelsByCategoryToken.get(category.category_token); + if (models === undefined || models.size === 0) { + return; // If there are no models at the leaves, skip + } + dropdownOptions.push( + + ); + }); + + let selectedCategoryOption = undefined; + if (selectedCategory !== undefined) { + selectedCategoryOption = { + value: selectedCategory.category_token, + label: selectedCategory.name_for_dropdown, + }; + } + + if (dropdownOptions.length <= 1) { + // We've run out of subcategories. (1 == "Select...") + // No sense trying to build more. + break; + } + + let selectProps: any = { + options: options, + classNames: SearchFieldClass, + className: "w-100", + autoFocus: false, // On mobile, we don't want the onscreen keyboard to take up half the UI. + isSearchable: false, // On mobile, we don't want the onscreen keyboard to take up half the UI. + onMenuOpen: () => { + Analytics.ttsOpenCategorySelectMenu(); + }, + onChange: (option: any) => handleChangeCategory(i, option?.value), + }; + + if (selectedCategoryOption === undefined) { + // NB(bt, 2023-01-19): I'm not sure why we're having to do this to clear categories. + // If I had more time to spend with this library, I might have a better solution than this hack. + selectProps["value"] = { + value: "*", + label: t("tts.TtsModelListPage.exploreModal.selectCategoryText"), + }; + } else { + selectProps["value"] = selectedCategoryOption; + } + + categoryDropdowns.push( + +
+ + + + { + Analytics.ttsOpenCategorySelectMenu(); + }} + /> +
+ ); +}