diff --git a/index.html b/index.html index f1bce433..d393c45a 100644 --- a/index.html +++ b/index.html @@ -11,8 +11,8 @@
- - + + + + diff --git a/package.json b/package.json index b3dfd943..10a2a415 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "version": "0.0.0", "type": "module", "scripts": { - "start:telegram": "npx concurrently --kill-others-on-fail \"npm run dev:frontend:start\" \"npm run dev:api:start\" \"npm run dev:tunnel\"", - "start:browser": "npx concurrently --kill-others-on-fail \"npm run dev:frontend:start\" \"npm run dev:api:start\"", - "dev:frontend:start": "vite", - "dev:api:start": "npx wrangler pages dev /functions --compatibility-date=2023-09-22 --compatibility-flags=\"nodejs_compat\"", + "dev:browser": "npx concurrently --kill-others-on-fail \"npm run dev:frontend\" \"npm run dev:api\"", + "dev:telegram": "npx concurrently --kill-others-on-fail \"npm run dev:frontend\" \"npm run dev:api\" \"npm run dev:tunnel\"", + "dev:frontend": "vite", + "dev:api": "npx wrangler pages dev /functions --compatibility-date=2023-09-22 --compatibility-flags=\"nodejs_compat\"", "dev:tunnel": "../ngrok http --domain=causal-magpie-closing.ngrok-free.app 5173", "build": "cp index.build.html index.html && vite build", "typecheck": "npx tsc", diff --git a/src/api/api.ts b/src/api/api.ts index 47081703..ae4e6432 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -65,6 +65,10 @@ import { AiMassGenerateResponse, } from "../../functions/ai-mass-generate.ts"; import { UserPreviousPromptsResponse } from "../../functions/user-previous-prompts.ts"; +import { + AiSpeechGenerateRequest, + AiSpeechGenerateResponse, +} from "../../functions/ai-speech-generate.ts"; export const healthRequest = () => { return request("/health"); @@ -251,3 +255,11 @@ export const addCardsMultipleRequest = (body: AddCardsMultipleRequest) => { export const userPreviousPromptsRequest = () => { return request("/user-previous-prompts"); }; + +export const aiSpeechGenerateRequest = (body: AiSpeechGenerateRequest) => { + return request( + "/ai-speech-generate", + "POST", + body, + ); +}; diff --git a/src/lib/platform/browser/use-main-button-progress-browser.ts b/src/lib/platform/browser/use-main-button-progress-browser.ts index d40d788e..b0333e06 100644 --- a/src/lib/platform/browser/use-main-button-progress-browser.ts +++ b/src/lib/platform/browser/use-main-button-progress-browser.ts @@ -1,5 +1,5 @@ import { useMount } from "../../react/use-mount.ts"; -import { autorun } from "mobx"; +import { autorun, runInAction } from "mobx"; import { platform } from "../platform.ts"; import { assert } from "../../typescript/assert.ts"; import { BrowserPlatform } from "./browser-platform.ts"; @@ -7,11 +7,16 @@ import { BrowserPlatform } from "./browser-platform.ts"; export const useMainButtonProgressBrowser = (cb: () => boolean) => { useMount(() => { return autorun(() => { - assert(platform instanceof BrowserPlatform); if (cb()) { - platform.isMainButtonLoading.setTrue(); + runInAction(() => { + assert(platform instanceof BrowserPlatform); + platform.isMainButtonLoading.setTrue(); + }); } else { - platform.isMainButtonLoading.setFalse(); + runInAction(() => { + assert(platform instanceof BrowserPlatform); + platform.isMainButtonLoading.setFalse(); + }); } }); }); diff --git a/src/screens/deck-form/card-ai-speech.tsx b/src/screens/deck-form/card-ai-speech.tsx new file mode 100644 index 00000000..ab8da98f --- /dev/null +++ b/src/screens/deck-form/card-ai-speech.tsx @@ -0,0 +1,103 @@ +import { observer } from "mobx-react-lite"; +import { CardFormType } from "./store/deck-form-store.ts"; +import { useBackButton } from "../../lib/platform/use-back-button.ts"; +import { Screen } from "../shared/screen.tsx"; +import { AudioPlayer } from "../../ui/audio-player.tsx"; +import { useMainButton } from "../../lib/platform/use-main-button.ts"; +import { t } from "../../translations/t.ts"; +import { Button } from "../../ui/button.tsx"; +import { ButtonGrid } from "../../ui/button-grid.tsx"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { Flex } from "../../ui/flex.tsx"; +import { Chip } from "../../ui/chip.tsx"; +import { Input } from "../../ui/input.tsx"; +import { useState } from "react"; +import { AiSpeechGeneratorStore } from "./store/ai-speech-generator-store.ts"; + +type Props = { + cardForm: CardFormType; + onBack: () => void; +}; + +export const CardAiSpeech = observer((props: Props) => { + const { cardForm, onBack } = props; + + const [store] = useState(() => new AiSpeechGeneratorStore(cardForm)); + const { form } = store; + + useBackButton(() => { + onBack(); + }); + + useMainButton(t("go_back"), () => { + onBack(); + }); + + return ( + + {cardForm.options.value?.voice ? ( + <> + + + + + + ) : ( + <> +
+ {t("ai_speech_empty")} + + {(["front", "back"] as const).map((side) => { + return ( + { + form.sourceSide.onChange(side); + }} + > + {t(side)} + + ); + })} + + {t("ai_speech_type")} + +
+ + + + )} +
+ ); +}); diff --git a/src/screens/deck-form/card-form-view.tsx b/src/screens/deck-form/card-form-view.tsx index 1c8b7b3a..11187ead 100644 --- a/src/screens/deck-form/card-form-view.tsx +++ b/src/screens/deck-form/card-form-view.tsx @@ -5,7 +5,7 @@ import { useMainButton } from "../../lib/platform/use-main-button.ts"; import { t } from "../../translations/t.ts"; import { useMainButtonProgress } from "../../lib/platform/use-main-button-progress.tsx"; import { useBackButton } from "../../lib/platform/use-back-button.ts"; -import { isFormValid } from "mobx-form-lite"; +import { formTouchAll, isFormValid } from "mobx-form-lite"; import { Screen } from "../shared/screen.tsx"; import { Label } from "../../ui/label.tsx"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; @@ -25,6 +25,7 @@ import { ListHeader } from "../../ui/list-header.tsx"; import { formatCardType } from "./format-card-type.ts"; import { ListRightText } from "../../ui/list-right-text.tsx"; import { CardAnswerErrors } from "./card-answer-errors.tsx"; +import { boolNarrow } from "../../lib/typescript/bool-narrow.ts"; type Props = { cardFormStore: CardFormStoreInterface; @@ -113,7 +114,28 @@ export const CardFormView = observer((props: Props) => { cardFormStore.cardInnerScreen.onChange("cardType"); }, }, - ]} + userStore.canUseAiMassGenerate + ? { + icon: ( + + ), + text: t("ai_speech_title"), + onClick: () => { + if (!isFormValid(cardForm)) { + formTouchAll(cardForm); + return; + } + cardFormStore.cardInnerScreen.onChange("aiSpeech"); + }, + right: cardForm.options.value?.voice ? ( + + ) : undefined, + } + : undefined, + ].filter(boolNarrow)} /> {cardFormStore.cardForm ? ( diff --git a/src/screens/deck-form/card-form-wrapper.tsx b/src/screens/deck-form/card-form-wrapper.tsx index 647e82e4..743fea1f 100644 --- a/src/screens/deck-form/card-form-wrapper.tsx +++ b/src/screens/deck-form/card-form-wrapper.tsx @@ -7,6 +7,7 @@ import { CardPreview } from "./card-preview.tsx"; import { CardFormView } from "./card-form-view.tsx"; import { CardExample } from "./card-example.tsx"; import { CardType } from "./card-type.tsx"; +import { CardAiSpeech } from "./card-ai-speech.tsx"; type Props = { cardFormStore: CardFormStoreInterface; @@ -48,5 +49,14 @@ export const CardFormWrapper = observer((props: Props) => { ); } + if (cardFormStore.cardInnerScreen.value === "aiSpeech") { + return ( + cardFormStore.cardInnerScreen.onChange(null)} + /> + ); + } + return ; }); diff --git a/src/screens/deck-form/create-mock-card-preview-form.ts b/src/screens/deck-form/create-mock-card-preview-form.ts index 1e13a4c2..8733d716 100644 --- a/src/screens/deck-form/create-mock-card-preview-form.ts +++ b/src/screens/deck-form/create-mock-card-preview-form.ts @@ -5,6 +5,7 @@ import { import { ListField, TextField } from "mobx-form-lite"; import { CardAnswerType } from "../../../functions/db/custom-types.ts"; import { CardAnswerFormType } from "./store/deck-form-store.ts"; +import { DeckCardOptionsDbType } from "../../../functions/db/deck/decks-with-cards-schema.ts"; export const createMockCardPreviewForm = (card: { front: string; @@ -18,7 +19,7 @@ export const createMockCardPreviewForm = (card: { example: new TextField(card.example ?? ""), answerType: new TextField("remember"), answerFormType: "new", - options: null, + options: new TextField(null), answers: new ListField([]), answerId: "0", }, diff --git a/src/screens/deck-form/speaking-cards.tsx b/src/screens/deck-form/speaking-cards.tsx index 26746ab4..804c2a0b 100644 --- a/src/screens/deck-form/speaking-cards.tsx +++ b/src/screens/deck-form/speaking-cards.tsx @@ -72,8 +72,8 @@ export const SpeakingCards = observer(() => { value={deckFormStore.form.speakingCardsField.value} onChange={deckFormStore.form.speakingCardsField.onChange} options={[ - { value: "front", label: t("card_speak_side_front") }, - { value: "back", label: t("card_speak_side_back") }, + { value: "front", label: t("front") }, + { value: "back", label: t("back") }, ]} /> ) : null} diff --git a/src/screens/deck-form/store/ai-speech-generator-store.ts b/src/screens/deck-form/store/ai-speech-generator-store.ts new file mode 100644 index 00000000..c070dbc6 --- /dev/null +++ b/src/screens/deck-form/store/ai-speech-generator-store.ts @@ -0,0 +1,83 @@ +import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; +import { aiSpeechGenerateRequest } from "../../../api/api.ts"; +import { formTouchAll, isFormValid, TextField } from "mobx-form-lite"; +import { CardFormType } from "./deck-form-store.ts"; +import { makeAutoObservable } from "mobx"; +import { notifyError } from "../../shared/snackbar/snackbar.tsx"; +import { t } from "../../../translations/t.ts"; + +export class AiSpeechGeneratorStore { + speechGenerateRequest = new RequestStore(aiSpeechGenerateRequest); + + form = { + sourceText: new TextField("", { + validate: (value) => { + if (!value && !this.form.sourceSide.value) { + return t("ai_speech_validate"); + } + }, + afterChange: (newValue) => { + if (newValue && this.form.sourceSide.value !== null) { + this.form.sourceSide.onChange(null); + } + }, + }), + sourceSide: new TextField<"front" | "back" | null>(null), + }; + + constructor(public cardForm: CardFormType) { + makeAutoObservable( + this, + { + cardForm: false, + }, + { autoBind: true }, + ); + } + + get isLoading() { + return this.speechGenerateRequest.isLoading; + } + + async generate() { + if (!isFormValid(this.form)) { + formTouchAll(this.form); + return; + } + + const text = (() => { + if (this.form.sourceText.value) { + return this.form.sourceText.value; + } + if (this.form.sourceSide.value) { + return this.cardForm[this.form.sourceSide.value].value; + } + throw new Error("Unexpected state"); + })(); + + const result = await this.speechGenerateRequest.execute({ + text, + }); + + if (result.status === "error") { + notifyError({ e: result.error, info: "Error generating AI voice" }); + return; + } + if (!result.data.data) { + notifyError(false, { message: result.data.error }); + return; + } + + this.cardForm.options.onChange({ + ...(this.cardForm.options.value || {}), + voice: result.data.data.publicUrl, + }); + } + + onDeleteAiVoice() { + this.cardForm.options.onChange({ + ...(this.cardForm.options.value || {}), + voice: null, + }); + } +} diff --git a/src/screens/deck-form/store/card-form-store-interface.ts b/src/screens/deck-form/store/card-form-store-interface.ts index a5451976..9d77b1cc 100644 --- a/src/screens/deck-form/store/card-form-store-interface.ts +++ b/src/screens/deck-form/store/card-form-store-interface.ts @@ -2,7 +2,12 @@ import { CardFormType } from "./deck-form-store.ts"; import { TextField } from "mobx-form-lite"; import { DeckSpeakFieldEnum } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; -export type CardInnerScreenType = "cardPreview" | "cardType" | "example" | null; +export type CardInnerScreenType = + | "cardPreview" + | "cardType" + | "example" + | "aiSpeech" + | null; export interface CardFormStoreInterface { cardForm?: CardFormType | null; diff --git a/src/screens/deck-form/store/deck-form-store.test.ts b/src/screens/deck-form/store/deck-form-store.test.ts index 5505f9c1..02f11fba 100644 --- a/src/screens/deck-form/store/deck-form-store.test.ts +++ b/src/screens/deck-form/store/deck-form-store.test.ts @@ -213,6 +213,7 @@ describe("deck form store", () => { "example": "", "front": "1 (new)", "id": undefined, + "options": null, }, ] `); @@ -252,6 +253,7 @@ describe("deck form store", () => { "example": "", "front": "1 (new)", "id": undefined, + "options": null, }, { "answerType": "remember", @@ -260,6 +262,7 @@ describe("deck form store", () => { "example": "", "front": "2 (edited)", "id": 3, + "options": null, }, { "answerType": "remember", @@ -268,6 +271,7 @@ describe("deck form store", () => { "example": "", "front": "3 (edited)", "id": 4, + "options": null, }, ] `); diff --git a/src/screens/deck-form/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts index 982bb0d8..7301f522 100644 --- a/src/screens/deck-form/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -50,7 +50,7 @@ export type CardFormType = { answerId?: string; answerFormType?: "new" | "edit"; id?: number; - options: DeckCardOptionsDbType; + options: TextField; }; type DeckFormType = { @@ -132,7 +132,7 @@ const createUpdateForm = ( back: createCardSideField(card.back), example: new TextField(card.example || ""), answerType: createAnswerTypeField(card), - options: card.options, + options: new TextField(card.options ?? null), answers: createAnswerListField( card.answers ? card.answers.map((answer) => ({ @@ -157,6 +157,7 @@ const cardFormToApi = ( back: card.back.value, example: card.example.value, answerType: card.answerType.value, + options: card.options.value, answers: card.answers.value.map((answer) => ({ id: answer.id, text: answer.text.value, @@ -347,7 +348,7 @@ export class DeckFormStore implements CardFormStoreInterface { back: createCardSideField(""), example: new TextField(""), answerType: createAnswerTypeField(), - options: null, + options: new TextField(null), answers: createAnswerListField([], () => this.cardForm), }); } diff --git a/src/screens/deck-form/store/quick-add-card-form-store.ts b/src/screens/deck-form/store/quick-add-card-form-store.ts index 7719cae2..0c8fb536 100644 --- a/src/screens/deck-form/store/quick-add-card-form-store.ts +++ b/src/screens/deck-form/store/quick-add-card-form-store.ts @@ -23,7 +23,10 @@ import { CardFormStoreInterface, CardInnerScreenType, } from "./card-form-store-interface.ts"; -import { DeckSpeakFieldEnum } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; +import { + DeckCardOptionsDbType, + DeckSpeakFieldEnum, +} from "../../../../functions/db/deck/decks-with-cards-schema.ts"; import { RequestStore } from "../../../lib/mobx-request/request-store.ts"; import { notifyError, notifySuccess } from "../../shared/snackbar/snackbar.tsx"; @@ -33,7 +36,7 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { front: createCardSideField(""), example: new TextField(""), answerType: createAnswerTypeField(), - options: null, + options: new TextField(null), answers: createAnswerListField([], () => this.cardForm), }; addCardRequest = new RequestStore(addCardRequest); diff --git a/src/screens/deck-review/store/card-preview-store.ts b/src/screens/deck-review/store/card-preview-store.ts index f89a57ec..0c9db186 100644 --- a/src/screens/deck-review/store/card-preview-store.ts +++ b/src/screens/deck-review/store/card-preview-store.ts @@ -65,8 +65,8 @@ export class CardPreviewStore implements LimitedCardUnderReviewStore { this.deckSpeakLocale = deckForm.speakingCardsLocale.value ?? null; this.deckSpeakField = deckForm.speakingCardsField.value ?? null; - if (form.options?.voice) { - const audio = new Audio(form.options.voice); + if (form.options.value?.voice) { + const audio = new Audio(form.options.value.voice); // Preload audio to avoid slow delay when playing voice audio.load(); this.voice = audio; diff --git a/src/screens/plans/translations.ts b/src/screens/plans/translations.ts index 2272ab18..5a8ff3c5 100644 --- a/src/screens/plans/translations.ts +++ b/src/screens/plans/translations.ts @@ -20,35 +20,35 @@ export const getPlanDescription = (plan: PlanDb) => { switch (lang) { case "en": return [ - "Card mass creation tools using AI", + "Card mass creation using AI", + "High quality AI speech generation for cards", "Duplicate folder, deck", "One time deck links and one time folder links", "Specify deck and folder access duration", - "High priority support", ]; case "ru": return [ - "Инструменты массового создания карточек с использованием ИИ", + "Массовое создание карточек с использованием ИИ", + "Озвучка карточек с использованием ИИ", "Дублирование папок, колод", "Одноразовые ссылки на колоды и папки", - "Указание длительность доступа к колодам и папкам", - "Приоритетная поддержка", + "Управление длительностью доступа к колодам и папкам", ]; case "es": return [ "Herramientas de creación masiva de tarjetas utilizando IA", + "Doblaje de alta calidad para tarjetas", "Duplicar carpeta, baraja", "Enlaces de carpeta y baraja de un solo uso", "Especificar la duración del acceso a la carpeta y la baraja", - "Soporte prioritario", ]; case "pt-br": return [ "Ferramentas de criação em massa de cartões usando IA", + "Dublagem de alta qualidade para cartões", "Duplicar pasta, baralho", "Links de pasta e baralho de uso único", "Especificar a duração do acesso à pasta e ao baralho", - "Suporte prioritário", ]; default: return lang satisfies never; diff --git a/src/translations/t.ts b/src/translations/t.ts index e61df774..e809d01a 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -90,8 +90,8 @@ const en = { speaking_cards: "Speaking cards", voice_language: "Voice language", card_speak_side: "Speak side", - card_speak_side_front: "Front", - card_speak_side_back: "Back", + front: "Front", + back: "Back", card_speak_description: "Play spoken audio for each flashcard to listen to the pronunciation", review_deck_finished: `You have finished this deck for now 🎉`, @@ -176,6 +176,7 @@ const en = { review_idk: "I don't know", card_answer_type: "Card type", yes_no: "Remember", + yes: 'Yes', answer_type_choice: "Quiz", answer_type_explanation_remember: `A card with "Remember" and "Don't remember" buttons`, answer_type_explanation_choice: `A card with answer choices`, @@ -249,11 +250,22 @@ const en = { confirm_ok: "Confirm", payment_failed: "Payment failed. We're aware of the issue and working on it. Please contact support via Settings > Support.", + ai_speech_title: "AI speech", + ai_speech_empty: "No AI speech generated. Choose the card side below.", + ai_speech_type: "Or type the needed text", + ai_speech_generate: "Generate", + ai_speech_validate: "Please either select side or type the text", }; type Translation = typeof en; const ru: Translation = { + yes: 'Да', + ai_speech_title: "ИИ речь", + ai_speech_empty: "Речь не создана. Выберите сторону карточки ниже.", + ai_speech_generate: "Сгенерировать речь", + ai_speech_type: "Или введите текст", + ai_speech_validate: "Пожалуйста выберите сторону или введите текст", confirm_cancel: "Отмена", confirm_ok: "Подтвердить", payment_success: @@ -378,8 +390,8 @@ const ru: Translation = { speaking_cards: "Озвучка карточек", voice_language: "Язык озвучки", card_speak_side: "Сторона карточки", - card_speak_side_front: "Лицевая", - card_speak_side_back: "Обратная", + front: "Лицевая", + back: "Обратная", card_speak_description: "Позволяет услышать произношение", review_deck_finished: `Колода пройдена 🎉`, review_all_cards: `Вы повторили все карточки на сегодня 🎉`, @@ -502,6 +514,13 @@ const ru: Translation = { }; const es: Translation = { + yes: 'Sí', + ai_speech_validate: "Por favor, selecciona una cara o escribe el texto", + ai_speech_type: "O escribe el texto necesario", + ai_speech_empty: + "No se ha generado ninguna voz de IA. Elige el lado de la tarjeta a continuación.", + ai_speech_title: "Voz de IA", + ai_speech_generate: "Generar", confirm_ok: "Confirmar", confirm_cancel: "Cancelar", payment_success: @@ -632,8 +651,8 @@ const es: Translation = { speaking_cards: "Tarjetas habladas", voice_language: "Idioma de voz", card_speak_side: "Lado de la tarjeta", - card_speak_side_front: "Frente", - card_speak_side_back: "Dorso", + front: "Frente", + back: "Dorso", card_speak_description: "Reproducir audio hablado para cada tarjeta de memoria para escuchar la pronunciación.", review_deck_finished: `Has terminado este mazo por ahora 🎉`, @@ -752,6 +771,12 @@ const es: Translation = { }; const ptBr: Translation = { + yes: 'Sim', + ai_speech_generate: "Gerar", + ai_speech_title: "Voz de IA", + ai_speech_empty: "Nenhuma voz de IA gerada. Escolha o lado do cartão abaixo.", + ai_speech_type: "Ou digite o texto necessário", + ai_speech_validate: "Por favor, selecione um lado ou digite o texto", confirm_ok: "Confirmar", confirm_cancel: "Cancelar", payment_success: @@ -885,8 +910,8 @@ const ptBr: Translation = { speaking_cards: "Cartões com voz", voice_language: "Idioma da voz", card_speak_side: "Lado do cartão", - card_speak_side_front: "Frente", - card_speak_side_back: "Verso", + front: "Frente", + back: "Verso", card_speak_description: "Reproduzir áudio falado para cada flashcard para ouvir a pronúncia.", review_deck_finished: `Parabéns! Você terminou este baralho por enquanto. 🎉`, diff --git a/src/ui/audio-player.tsx b/src/ui/audio-player.tsx new file mode 100644 index 00000000..3731524e --- /dev/null +++ b/src/ui/audio-player.tsx @@ -0,0 +1,21 @@ +import { css } from "@emotion/css"; + +type Props = { src: string }; + +export const AudioPlayer = (props: Props) => { + const { src } = props; + + return ( +