diff --git a/package-lock.json b/package-lock.json index 75a1a239..61f5e06d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "memo-card", "version": "0.0.0", - "hasInstallScript": true, "dependencies": { "@emotion/css": "^11.11.2", "@mdi/font": "^5.9.55", @@ -27,6 +26,7 @@ "mobx-log": "^2.2.3", "mobx-persist-store": "^1.1.3", "mobx-react-lite": "^4.0.5", + "notistack": "^3.0.1", "openai": "^4.33.1", "react": "^18.2.0", "react-content-loader": "^6.2.1", @@ -2566,6 +2566,14 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/cmd-shim": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-6.0.2.tgz", @@ -4082,6 +4090,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -5349,6 +5365,27 @@ "node": ">=0.10.0" } }, + "node_modules/notistack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.1.tgz", + "integrity": "sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/npm-normalize-package-bin": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", diff --git a/package.json b/package.json index 436d5239..f1cdeadf 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "mobx-log": "^2.2.3", "mobx-persist-store": "^1.1.3", "mobx-react-lite": "^4.0.5", + "notistack": "^3.0.1", "openai": "^4.33.1", "react": "^18.2.0", "react-content-loader": "^6.2.1", diff --git a/shared/access/can-use-ai-mass-generate.ts b/shared/access/can-use-ai-mass-generate.ts new file mode 100644 index 00000000..cf9a84c0 --- /dev/null +++ b/shared/access/can-use-ai-mass-generate.ts @@ -0,0 +1,13 @@ +import type { PlansForUser } from "../../functions/db/plan/get-active-plans-for-user.ts"; +import type { UserDbType } from "../../functions/db/user/upsert-user-db.ts"; + +export const canUseAiMassGenerate = ( + user: UserDbType, + plans?: PlansForUser, +) => { + if (user.is_admin) { + return true; + } + + return plans?.some((plan) => plan.ai_mass_generate); +}; diff --git a/src/api/api.ts b/src/api/api.ts index 72287524..5931f084 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -51,6 +51,19 @@ import { CardsFreezeResponse, } from "../../functions/cards-freeze.ts"; import { DeleteFolderResponse } from "../../functions/delete-folder.ts"; +import { UserAiCredentialsResponse } from "../../functions/user-ai-credentials.ts"; +import { + UpsertUserAiCredentialsRequest, + UpsertUserAiCredentialsResponse, +} from "../../functions/upsert-user-ai-credentials.ts"; +import { + AddCardsMultipleRequest, + AddCardsMultipleResponse, +} from "../../functions/add-cards-multiple.ts"; +import { + AiMassGenerateRequest, + AiMassGenerateResponse, +} from "../../functions/ai-mass-generate.ts"; export const healthRequest = () => { return request("/health"); @@ -204,3 +217,33 @@ export const cardsFreezeRequest = (body: CardsFreezeRequest) => { body, ); }; + +export const aiMassGenerateRequest = (body: AiMassGenerateRequest) => { + return request( + // TODO: remove mock + "/ai-mass-generate-mock", + "POST", + body, + ); +}; + +export const aiUserCredentialsCheckRequest = () => { + return request("/user-ai-credentials", "GET"); +}; + +export const upsertUserAiCredentialsRequest = ( + body: UpsertUserAiCredentialsRequest, +) => { + return request< + UpsertUserAiCredentialsResponse, + UpsertUserAiCredentialsRequest + >("/upsert-user-ai-credentials", "POST", body); +}; + +export const addCardsMultipleRequest = (body: AddCardsMultipleRequest) => { + return request( + "/add-cards-multiple", + "POST", + body, + ); +}; diff --git a/src/lib/mobx-request/request.test.ts b/src/lib/mobx-request/request.test.ts new file mode 100644 index 00000000..c82a0a5f --- /dev/null +++ b/src/lib/mobx-request/request.test.ts @@ -0,0 +1,13 @@ +import { RequestStore } from "./requestStore.ts"; +import { expect, test } from "vitest"; +import { when } from "mobx"; + +test("request - success", async () => { + const sum = (a: number, b: number) => Promise.resolve(a + b); + const request = new RequestStore(sum); + + expect(request.result).toEqual({ data: null, status: "idle" }); + request.execute(1, 2); + await when(() => request.result.status === "success"); + expect(request.result).toEqual({ data: 3, status: "success" }); +}); diff --git a/src/lib/mobx-request/requestStore.ts b/src/lib/mobx-request/requestStore.ts new file mode 100644 index 00000000..cf9b152d --- /dev/null +++ b/src/lib/mobx-request/requestStore.ts @@ -0,0 +1,52 @@ +import { makeAutoObservable, runInAction } from "mobx"; + +type SuccessResult = { data: T; status: "success" }; +type ErrorResult = { data: null; status: "error"; error: any }; +type LoadingResult = { data: null; status: "loading" }; +type IdleResult = { data: null; status: "idle" }; + +type Result = SuccessResult | ErrorResult | LoadingResult | IdleResult; +type ExecuteResult = SuccessResult | ErrorResult; + +export class RequestStore { + result: Result = { data: null, status: "idle" }; + + constructor(private fetchFn: (...args: Args) => Promise) { + makeAutoObservable( + this, + { fetchFn: false }, + { autoBind: true }, + ); + } + + execute = async (...args: Args): Promise> => { + this.result = { data: null, status: "loading" }; + + try { + const data = await this.fetchFn(...args); + runInAction(() => { + this.result = { data, status: "success" }; + }); + } catch (error) { + runInAction(() => { + this.result = { data: null, status: "error", error }; + }); + } + + return this.result as unknown as ExecuteResult; + }; + + overrideSuccess(data: T) { + this.result = { data, status: "success" }; + } + + // Non type-safe shorthand + get isLoading() { + return this.result.status === "loading"; + } + + // Non type-safe shorthand + get isSuccess() { + return this.result.status === "success"; + } +} diff --git a/src/lib/request/request.ts b/src/lib/request/request.ts index 1f98ceae..2cc0e33a 100644 --- a/src/lib/request/request.ts +++ b/src/lib/request/request.ts @@ -41,7 +41,7 @@ export const request = async ( try { return await requestInner(path, method, body); } catch (error) { - if (method === "GET") { + if (method === "GET" || path === "/upsert-deck") { return requestInner(path, method, body); } throw error; diff --git a/src/screens/ai-mass-creation/ai-mass-creation-form.tsx b/src/screens/ai-mass-creation/ai-mass-creation-form.tsx new file mode 100644 index 00000000..f83f1d10 --- /dev/null +++ b/src/screens/ai-mass-creation/ai-mass-creation-form.tsx @@ -0,0 +1,87 @@ +import { observer } from "mobx-react-lite"; +import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx"; +import { Screen } from "../shared/screen.tsx"; +import { Flex } from "../../ui/flex.tsx"; +import { List } from "../../ui/list.tsx"; +import { FilledIcon } from "../../ui/filled-icon.tsx"; +import { theme } from "../../ui/theme.tsx"; +import { ListRightText } from "../../ui/list-right-text.tsx"; +import { t } from "../../translations/t.ts"; +import { Label } from "../../ui/label.tsx"; +import { Input } from "../../ui/input.tsx"; +import React from "react"; +import { ValidationError } from "../../ui/validation-error.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; + +export const AiMassCreationForm = observer(() => { + const store = useAiMassCreationStore(); + const { promptForm } = store; + + useMainButton("Generate cards", () => { + store.submitPromptForm(); + }); + + useTelegramProgress(() => store.aiMassGenerateRequest.isLoading); + + return ( + + + + ), + onClick: () => { + store.screen.onChange("how"); + }, + }, + { + text: "API keys", + icon: ( + + ), + right: ( + + ), + onClick: () => { + store.goApiKeysScreen(); + }, + }, + ]} + /> + {promptForm.apiKey.isTouched && promptForm.apiKey.error && ( + + )} + + + + + + + + + ); +}); diff --git a/src/screens/ai-mass-creation/ai-mass-creation-screen.tsx b/src/screens/ai-mass-creation/ai-mass-creation-screen.tsx new file mode 100644 index 00000000..c12d0429 --- /dev/null +++ b/src/screens/ai-mass-creation/ai-mass-creation-screen.tsx @@ -0,0 +1,27 @@ +import { observer } from "mobx-react-lite"; +import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx"; +import React from "react"; +import { AiMassCreationForm } from "./ai-mass-creation-form.tsx"; +import { HowMassCreationWorksScreen } from "./how-mass-creation-works-screen.tsx"; +import { ApiKeysScreen } from "./api-keys-screen.tsx"; +import { useMount } from "../../lib/react/use-mount.ts"; +import { CardsGeneratedScreen } from "./cards-generated-screen.tsx"; + +export const AiMassCreationScreen = observer(() => { + const store = useAiMassCreationStore(); + + useMount(() => { + store.load(); + }); + + if (store.screen.value === "how") { + return ; + } + if (store.screen.value === "apiKeys") { + return ; + } + if (store.screen.value === "cardsGenerated") { + return ; + } + return ; +}); diff --git a/src/screens/ai-mass-creation/api-keys-screen.tsx b/src/screens/ai-mass-creation/api-keys-screen.tsx new file mode 100644 index 00000000..36c852fc --- /dev/null +++ b/src/screens/ai-mass-creation/api-keys-screen.tsx @@ -0,0 +1,93 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { Label } from "../../ui/label.tsx"; +import { Input } from "../../ui/input.tsx"; +import React from "react"; +import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx"; +import { HintTransparent } from "../../ui/hint-transparent.tsx"; +import { ExternalLink } from "../../ui/external-link.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { t } from "../../translations/t.ts"; +import { SelectWithChevron } from "../../ui/select-with-chevron.tsx"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { Flex } from "../../ui/flex.tsx"; +import { chatGptModels } from "./store/ai-mass-creation-store.ts"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; +import { TextField } from "mobx-form-lite"; + +export const ApiKeysScreen = observer(() => { + const store = useAiMassCreationStore(); + const { apiKeysForm } = store; + + useMainButton(t("save"), () => { + store.submitApiKeysForm(); + }); + + useBackButton(() => { + store.screen.onChange(null); + }); + + useTelegramProgress(() => store.upsertUserAiCredentialsRequest.isLoading); + + const isRegularInput = store.isApiKeyRegularInput; + + return ( + + + + +
+ Model +
+ { + apiKeysForm.model.onChange(value); + }} + options={chatGptModels.map((model) => ({ + label: model, + value: model, + }))} + /> +
+
+ ); +}); diff --git a/src/screens/ai-mass-creation/cards-generated-screen.tsx b/src/screens/ai-mass-creation/cards-generated-screen.tsx new file mode 100644 index 00000000..814d7eff --- /dev/null +++ b/src/screens/ai-mass-creation/cards-generated-screen.tsx @@ -0,0 +1,114 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { List } from "../../ui/list.tsx"; +import { ListHeader } from "../../ui/list-header.tsx"; +import { assert } from "../../lib/typescript/assert.ts"; +import { css, cx } from "@emotion/css"; +import { reset } from "../../ui/reset.ts"; +import { theme } from "../../ui/theme.tsx"; +import React from "react"; +import { t } from "../../translations/t.ts"; +import { screenStore } from "../../store/screen-store.ts"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; +import { CardNumber } from "../../ui/card-number.tsx"; +import { showConfirm } from "../../lib/telegram/show-confirm.ts"; + +export const CardsGeneratedScreen = observer(() => { + const store = useAiMassCreationStore(); + assert(store.massCreationForm); + const screen = screenStore.screen; + assert(screen.type === "aiMassCreation"); + + useBackButton(() => { + store.onQuitBack(); + }); + + useMainButton( + () => { + assert(store.massCreationForm); + const count = store.massCreationForm.cards.value.length; + return `Add ${count} cards`; + }, + () => { + store.submitMassCreationForm(); + }, + ); + + useTelegramProgress(() => store.addCardsMultipleRequest.isLoading); + + return ( + + {t("deck")}{" "} + + + ) : undefined + } + > +
+ + ({ + text: ( +
+
+ + {card.front} +
+
+ {card.back} +
+
+ ), + right: ( + + ), + }))} + /> +
+
+ ); +}); diff --git a/src/screens/ai-mass-creation/how-mass-creation-works-screen.tsx b/src/screens/ai-mass-creation/how-mass-creation-works-screen.tsx new file mode 100644 index 00000000..bc8d768a --- /dev/null +++ b/src/screens/ai-mass-creation/how-mass-creation-works-screen.tsx @@ -0,0 +1,88 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { t } from "../../translations/t.ts"; +import { css } from "@emotion/css"; +import React from "react"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useAiMassCreationStore } from "./store/ai-mass-creation-store-provider.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { theme } from "../../ui/theme.tsx"; + +export const HowMassCreationWorksScreen = observer(() => { + const store = useAiMassCreationStore(); + + useBackButton(() => { + store.screen.onChange(null); + }); + + useMainButton("Understood", () => { + store.screen.onChange(null); + }); + + return ( + +
+
+ This option allows you to generate multiple cards at once using AI and + your own API key. +
+
+ Example 1: +
    +
  • + Prompt: Generate 3 cards with capitals of the world +
  • +
  • + Card front description: Country +
  • +
  • + Card back description: Capital +
  • +
+
+ You will get cards like Germany - Berlin, France - Paris, Canada - + Ottawa +
+
+ +
+ Example 2: +
    +
  • + Prompt: Generate 2 cards with English French words related + to fruits +
  • +
  • + Card front description: Fruit in English +
  • +
  • + Card back description: Fruit in French +
  • +
+
You will get cards like Apple - Pomme, Banana - Banane
+
+
+
+ ); +}); diff --git a/src/screens/ai-mass-creation/store/ai-mass-creation-store-provider.tsx b/src/screens/ai-mass-creation/store/ai-mass-creation-store-provider.tsx new file mode 100644 index 00000000..6ec6ae31 --- /dev/null +++ b/src/screens/ai-mass-creation/store/ai-mass-creation-store-provider.tsx @@ -0,0 +1,23 @@ +import { createContext, ReactNode, useContext } from "react"; +import { AiMassCreationStore } from "./ai-mass-creation-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; + +const Context = createContext(null); + +export const AiMassCreationStoreProvider = ({ + children, +}: { + children: ReactNode; +}) => { + return ( + + {children} + + ); +}; + +export const useAiMassCreationStore = () => { + const store = useContext(Context); + assert(store, "AiMassCreationStoreProvider should be defined"); + return store; +}; diff --git a/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts b/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts new file mode 100644 index 00000000..b5399e21 --- /dev/null +++ b/src/screens/ai-mass-creation/store/ai-mass-creation-store.test.ts @@ -0,0 +1,75 @@ +import { test, vi, expect } from "vitest"; +import { AiMassCreationStore } from "./ai-mass-creation-store.ts"; +import { when } from "mobx"; +import { isFormValid } from "mobx-form-lite"; + +const aiUserCredentialsCheckRequestMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../api/api.ts", () => { + return { + aiUserCredentialsCheckRequest: aiUserCredentialsCheckRequestMock, + upsertUserAiCredentialsRequest: vi.fn(() => Promise.resolve()), + aiMassGenerateRequest: vi.fn(() => Promise.resolve()), + addCardsMultipleRequest: vi.fn(() => Promise.resolve()), + }; +}); + +vi.mock("../../../translations/t.ts", () => { + return { + t: (key: string) => key, + }; +}); + +vi.mock("../../shared/snackbar.tsx", () => { + return { + showSnackBar: vi.fn(), + }; +}); + +vi.mock("../../../lib/telegram/show-confirm.ts", () => { + return { + showConfirm: vi.fn(), + }; +}); + +vi.mock("../../../store/deck-list-store.ts", () => { + return { + deckListStore: {}, + }; +}); + +test("ai mass creation store - api keys form - initial state", async () => { + aiUserCredentialsCheckRequestMock.mockResolvedValueOnce( + Promise.resolve({ + is_ai_credentials_set: false, + }), + ); + const store = new AiMassCreationStore(); + store.load(); + await when(() => store.isApiKeysSetRequest.isSuccess); + expect(store.isApiKeysSet).toBeFalsy(); + expect(store.isApiKeyRegularInput).toBeTruthy(); + expect(isFormValid(store.apiKeysForm)).toBeFalsy(); + store.apiKeysForm.apiKey.onChange("test"); + expect(isFormValid(store.apiKeysForm)).toBeTruthy(); +}); + +test("ai mass creation store - api keys form - api key is configured", async () => { + aiUserCredentialsCheckRequestMock.mockResolvedValueOnce( + Promise.resolve({ + is_ai_credentials_set: true, + }), + ); + const store = new AiMassCreationStore(); + store.load(); + await when(() => store.isApiKeysSetRequest.isSuccess); + expect(store.isApiKeysSet).toBeTruthy(); + expect(store.isApiKeyRegularInput).toBeFalsy(); + expect(isFormValid(store.apiKeysForm)).toBeTruthy(); + + store.forceUpdateApiKey.setTrue(); + expect(store.isApiKeyRegularInput).toBeTruthy(); + expect(isFormValid(store.apiKeysForm)).toBeFalsy(); + store.apiKeysForm.apiKey.onChange("test"); + expect(isFormValid(store.apiKeysForm)).toBeTruthy(); +}); diff --git a/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts b/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts new file mode 100644 index 00000000..0fd0bd90 --- /dev/null +++ b/src/screens/ai-mass-creation/store/ai-mass-creation-store.ts @@ -0,0 +1,226 @@ +import { makeAutoObservable } from "mobx"; +import { + BooleanToggle, + formTouchAll, + isFormValid, + ListField, + TextField, + validators, +} from "mobx-form-lite"; +import { t } from "../../../translations/t.ts"; +import { + addCardsMultipleRequest, + aiMassGenerateRequest, + aiUserCredentialsCheckRequest, + upsertUserAiCredentialsRequest, +} from "../../../api/api.ts"; +import { RequestStore } from "../../../lib/mobx-request/requestStore.ts"; +import { screenStore } from "../../../store/screen-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; +import { notifySuccess } from "../../shared/snackbar.tsx"; +import { deckListStore } from "../../../store/deck-list-store.ts"; +import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; + +export const chatGptModels = [ + "gpt-4-0125-preview", + "gpt-4-turbo-preview", + "gpt-4-1106-preview", + "gpt-4-vision-preview", + "gpt-4", + "gpt-4-0314", + "gpt-4-0613", + "gpt-4-32k", + "gpt-4-32k-0314", + "gpt-4-32k-0613", + "gpt-3.5-turbo", + "gpt-3.5-turbo-16k", + "gpt-3.5-turbo-0301", + "gpt-3.5-turbo-0613", + "gpt-3.5-turbo-1106", + "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-16k-0613", +]; + +type ChatGptModel = (typeof chatGptModels)[number]; + +export class AiMassCreationStore { + upsertUserAiCredentialsRequest = new RequestStore( + upsertUserAiCredentialsRequest, + ); + isApiKeysSetRequest = new RequestStore(aiUserCredentialsCheckRequest); + aiMassGenerateRequest = new RequestStore(aiMassGenerateRequest); + addCardsMultipleRequest = new RequestStore(addCardsMultipleRequest); + + screen = new TextField<"how" | "apiKeys" | "cardsGenerated" | null>(null); + forceUpdateApiKey = new BooleanToggle(false); + + promptForm = { + prompt: new TextField("", { + validate: validators.required(t("validation_required")), + }), + frontPrompt: new TextField("", { + validate: validators.required(t("validation_required")), + }), + backPrompt: new TextField("", { + validate: validators.required(t("validation_required")), + }), + // A field to just show error on submit + apiKey: new TextField("", { + validate: () => { + if (!this.isApiKeysSet) { + return "API key is required"; + } + }, + }), + }; + + apiKeysForm = { + apiKey: new TextField("", { + validate: (value) => { + if (!this.isApiKeysSet || this.forceUpdateApiKey.value) { + return validators.required(t("validation_required"))(value); + } + }, + }), + model: new TextField("gpt-3.5-turbo"), + }; + + massCreationForm?: { + cards: ListField<{ front: string; back: string }>; + }; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + load() { + this.isApiKeysSetRequest.execute(); + } + + goApiKeysScreen() { + if (!this.isApiKeysSetRequest.isSuccess) { + return; + } + this.screen.onChange("apiKeys"); + } + + get isApiKeysSet() { + return this.isApiKeysSetRequest.result.status === "success" + ? this.isApiKeysSetRequest.result.data.is_ai_credentials_set + : false; + } + + get isApiKeyRegularInput() { + if (this.forceUpdateApiKey.value) { + return true; + } + return !this.isApiKeysSet; + } + + submitApiKeysForm() { + if (!isFormValid(this.apiKeysForm)) { + formTouchAll(this.apiKeysForm); + return; + } + + this.upsertUserAiCredentialsRequest + .execute({ + open_ai_key: this.apiKeysForm.apiKey.value, + open_ai_model: this.apiKeysForm.model.value, + }) + .then(() => { + this.load(); + this.screen.onChange(null); + this.apiKeysForm.apiKey.onChange(""); + }); + } + + private async onQuit(redirect: () => void) { + const isConfirmed = await showConfirm( + "Are you sure you want to quit without saving?", + ); + if (isConfirmed) { + redirect(); + } + } + + onQuitToDeck() { + this.onQuit(() => { + const { screen } = screenStore; + assert(screen.type === "aiMassCreation", "Invalid screen type"); + screenStore.go({ + type: "deckForm", + deckId: screen.deckId, + }); + }); + } + + onQuitBack() { + this.onQuit(() => { + this.screen.onChange(null); + }); + } + + submitPromptForm() { + if (!isFormValid(this.promptForm)) { + formTouchAll(this.promptForm); + return; + } + + this.aiMassGenerateRequest + .execute({ + prompt: this.promptForm.prompt.value, + frontPrompt: this.promptForm.frontPrompt.value, + backPrompt: this.promptForm.backPrompt.value, + }) + .then((result) => { + if (result.status === "success") { + const innerResult = result.data; + if (innerResult.data) { + this.massCreationForm = { + cards: new ListField<{ front: string; back: string }>( + innerResult.data.cards.map((card) => ({ + front: card.front, + back: card.back, + })), + ), + }; + this.screen.onChange("cardsGenerated"); + } else { + console.log(innerResult.error); + console.log("Error"); + } + } else { + throw new Error("Failed to generate cards"); + } + }); + } + + async submitMassCreationForm() { + if (!this.massCreationForm) { + return; + } + if (!isFormValid(this.massCreationForm)) { + formTouchAll(this.massCreationForm); + return; + } + + assert(screenStore.screen.type === "aiMassCreation", "Invalid screen type"); + + const result = await this.addCardsMultipleRequest.execute({ + deckId: screenStore.screen.deckId, + cards: this.massCreationForm.cards.value, + }); + + if (result.status !== "success") { + throw new Error("Failed to add cards"); + } + + notifySuccess("Cards added to the deck"); + deckListStore.replaceDeck(result.data.deck); + screenStore.go({ + type: "deckForm", + deckId: screenStore.screen.deckId, + }); + } +} diff --git a/src/screens/app.tsx b/src/screens/app.tsx index c1b5df55..825efc15 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -33,6 +33,9 @@ import { import { PlansScreen } from "./plans/plans-screen.tsx"; import { isRunningWithinTelegram } from "../lib/telegram/is-running-within-telegram.ts"; import { FreezeCardsScreenLazy } from "./freeze-cards/freeze-cards-screen-lazy.tsx"; +import { AiMassCreationScreen } from "./ai-mass-creation/ai-mass-creation-screen.tsx"; +import { AiMassCreationStoreProvider } from "./ai-mass-creation/store/ai-mass-creation-store-provider.tsx"; +import { SnackbarProviderWrapper } from "./shared/snackbar.tsx"; export const App = observer(() => { useRestoreFullScreenExpand(); @@ -56,6 +59,7 @@ export const App = observer(() => { return (
+ {screenStore.screen.type === "main" && ( @@ -148,6 +152,13 @@ export const App = observer(() => { )} + {screenStore.screen.type === "aiMassCreation" && ( + + + + + + )}
); }); diff --git a/src/screens/component-catalog/card-preview-story.tsx b/src/screens/component-catalog/card-preview-story.tsx index fc2b8881..fe34e8a2 100644 --- a/src/screens/component-catalog/card-preview-story.tsx +++ b/src/screens/component-catalog/card-preview-story.tsx @@ -1,7 +1,10 @@ import { CardPreview } from "../deck-form/card-preview.tsx"; import { useState } from "react"; -import { CardFormStoreInterface } from "../deck-form/store/card-form-store-interface.ts"; -import { TextField, BooleanToggle, ListField } from "mobx-form-lite"; +import { + CardFormStoreInterface, + CardInnerScreenType, +} from "../deck-form/store/card-form-store-interface.ts"; +import { ListField, TextField } from "mobx-form-lite"; import { CardAnswerType } from "../../../functions/db/custom-types.ts"; import { CardAnswerFormType } from "../deck-form/store/deck-form-store.ts"; @@ -22,7 +25,7 @@ const createCardPreviewForm = (card: { answerId: "0", }, form: undefined, - isCardPreviewSelected: new BooleanToggle(false), + cardInnerScreen: new TextField(null), onBackCard: () => {}, onSaveCard: () => {}, isSending: false, diff --git a/src/screens/component-catalog/components.tsx b/src/screens/component-catalog/components.tsx index dc7ac263..f620c8a2 100644 --- a/src/screens/component-catalog/components.tsx +++ b/src/screens/component-catalog/components.tsx @@ -3,6 +3,7 @@ import { ReactNode } from "react"; import { CardPreviewStory } from "./card-preview-story.tsx"; import { SelectStory } from "./select-story.tsx"; import { PieChartCanvasStory } from "./pie-chart-canvas-story.tsx"; +import { SnackbarStory } from "./snackbar-story.tsx"; export type Component = { name: string; @@ -51,4 +52,8 @@ export const components: Array = [ name: "PieChart", component: , }, + { + name: SnackbarStory.name, + component: , + }, ]; diff --git a/src/screens/component-catalog/snackbar-story.tsx b/src/screens/component-catalog/snackbar-story.tsx new file mode 100644 index 00000000..d870ad39 --- /dev/null +++ b/src/screens/component-catalog/snackbar-story.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { + notifyError, + notifySuccess, + SnackbarProviderWrapper, +} from "../shared/snackbar.tsx"; + +export const SnackbarStory = () => { + return ( +
+ + + + +
+ ); +}; diff --git a/src/screens/deck-form/answer-form-view.tsx b/src/screens/deck-form/answer-form-view.tsx index 72ef3e58..fdd69631 100644 --- a/src/screens/deck-form/answer-form-view.tsx +++ b/src/screens/deck-form/answer-form-view.tsx @@ -78,7 +78,7 @@ export const AnswerFormView = observer((props: Props) => { }); useMainButton( - t("card_answer_back"), + t("go_back"), () => { onSave(); }, diff --git a/src/screens/deck-form/card-answer-errors.tsx b/src/screens/deck-form/card-answer-errors.tsx new file mode 100644 index 00000000..3514b08b --- /dev/null +++ b/src/screens/deck-form/card-answer-errors.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react-lite"; +import { CardFormType } from "./store/deck-form-store.ts"; +import { isFormDirty, isFormTouched } from "mobx-form-lite"; +import { ValidationError } from "../../ui/validation-error.tsx"; +import React from "react"; + +type Props = { + cardForm: CardFormType; +}; + +export const CardAnswerErrors = observer((props: Props) => { + const { cardForm } = props; + + return ( + cardForm.answers.error && + (isFormTouched({ answers: cardForm.answers }) || + isFormDirty({ answers: cardForm.answers })) && ( + + ) + ); +}); diff --git a/src/screens/deck-form/card-example.tsx b/src/screens/deck-form/card-example.tsx new file mode 100644 index 00000000..58298ca9 --- /dev/null +++ b/src/screens/deck-form/card-example.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { Label } from "../../ui/label.tsx"; +import { t } from "../../translations/t.ts"; +import { FormattingSwitcher } from "./formatting-switcher.tsx"; +import { WysiwygField } from "../../ui/wysiwyg-field/wysiwig-field.tsx"; +import { Input } from "../../ui/input.tsx"; +import { HintTransparent } from "../../ui/hint-transparent.tsx"; +import React from "react"; +import { userStore } from "../../store/user-store.ts"; +import { CardFormType } from "./store/deck-form-store.ts"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; + +type Props = { + cardForm: CardFormType; + onBack: () => void; +}; + +export const CardExample = observer((props: Props) => { + const isCardFormattingOn = userStore.isCardFormattingOn.value; + const { cardForm, onBack } = props; + + useBackButton(() => { + onBack(); + }); + + useMainButton(t("go_back"), () => { + onBack(); + }); + + return ( + + + + ); +}); diff --git a/src/screens/deck-form/card-form-view.tsx b/src/screens/deck-form/card-form-view.tsx index 7130de67..e6b127b6 100644 --- a/src/screens/deck-form/card-form-view.tsx +++ b/src/screens/deck-form/card-form-view.tsx @@ -1,36 +1,30 @@ -import { observer, useLocalObservable } from "mobx-react-lite"; +import { observer } from "mobx-react-lite"; import { CardFormStoreInterface } from "./store/card-form-store-interface.ts"; import { assert } from "../../lib/typescript/assert.ts"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { t } from "../../translations/t.ts"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; -import { - BooleanToggle, - isFormDirty, - isFormTouched, - isFormValid, -} from "mobx-form-lite"; +import { isFormValid } from "mobx-form-lite"; import { Screen } from "../shared/screen.tsx"; import { Label } from "../../ui/label.tsx"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; -import { CardRow } from "../../ui/card-row.tsx"; -import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; -import { action } from "mobx"; -import { createAnswerForm } from "./store/deck-form-store.ts"; -import { css, cx } from "@emotion/css"; +import { css } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; import { ButtonGrid } from "../../ui/button-grid.tsx"; import { ButtonSideAligned } from "../../ui/button-side-aligned.tsx"; import React from "react"; -import { ValidationError } from "../../ui/validation-error.tsx"; import { WysiwygField } from "../../ui/wysiwyg-field/wysiwig-field.tsx"; import { userStore } from "../../store/user-store.ts"; import { Input } from "../../ui/input.tsx"; import { FormattingSwitcher } from "./formatting-switcher.tsx"; import { Flex } from "../../ui/flex.tsx"; import { List } from "../../ui/list.tsx"; -import { SelectWithChevron } from "../../ui/select-with-chevron.tsx"; +import { FilledIcon } from "../../ui/filled-icon.tsx"; +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"; type Props = { cardFormStore: CardFormStoreInterface; @@ -51,12 +45,6 @@ export const CardFormView = observer((props: Props) => { cardFormStore.onBackCard(); }); - const localStore = useLocalObservable(() => ({ - isAdvancedOn: new BooleanToggle( - cardForm.answerType.value !== "remember" || !!cardForm.example.value, - ), - })); - const isCardFormattingOn = userStore.isCardFormattingOn.value; return ( @@ -91,138 +79,67 @@ export const CardFormView = observer((props: Props) => { - - {t("card_advanced")} - + + + ), + text: t("card_field_example_title"), + onClick: () => { + cardFormStore.cardInnerScreen.onChange("example"); + }, + right: , + }, + { + icon: ( + + ), + text: t("card_answer_type"), + right: ( + + ), + onClick: () => { + cardFormStore.cardInnerScreen.onChange("cardType"); + }, + }, + ]} /> - - - {localStore.isAdvancedOn.value && ( - <> - - - - -
- {t("answer")} -
- { - cardForm?.answerType.onChange(value); - }} - options={[ - { label: t("yes_no"), value: "remember" }, - { label: t("answer_type_choice"), value: "choice_single" }, - ]} - /> -
- - {(() => { - switch (cardForm.answerType.value) { - case "remember": - return t("answer_type_explanation_remember"); - case "choice_single": - return t("answer_type_explanation_choice"); - default: - return cardForm.answerType.value satisfies never; - } - })()} - -
- - )} - - {localStore.isAdvancedOn.value && ( - <> - {cardForm.answerType.value === "choice_single" && ( - <> - { - return { - onClick: action(() => { - cardForm.answerId = answerForm.id; - cardForm.answerFormType = "edit"; - }), - text: answerForm.text.value, - right: answerForm.isCorrect.value ? ( - - ) : null, - }; - })} - /> - - {cardForm.answers.error && - (isFormTouched({ answers: cardForm.answers }) || - isFormDirty({ answers: cardForm.answers })) && ( - - )} - { - const answerForm = createAnswerForm(); - cardForm.answers.push(answerForm); - cardForm.answerId = answerForm.id; - cardForm.answerFormType = "new"; - })} - > - - {" "} - {t("add_answer")} - - - - )} - - )} + {cardFormStore.cardForm ? ( + + ) : null} +
{cardForm.id && ( <> - {cardFormStore.isPreviousCardVisible && ( - - {t("card_previous")} - - )} - {cardFormStore.isNextCardVisible && ( - - {t("card_next")} - - )} + + {t("card_previous")} + + + {t("card_next")} + )} @@ -230,7 +147,9 @@ export const CardFormView = observer((props: Props) => { { + cardFormStore.cardInnerScreen.onChange("cardPreview"); + }} > {t("card_preview")} diff --git a/src/screens/deck-form/card-form-wrapper.tsx b/src/screens/deck-form/card-form-wrapper.tsx index 526b349e..647e82e4 100644 --- a/src/screens/deck-form/card-form-wrapper.tsx +++ b/src/screens/deck-form/card-form-wrapper.tsx @@ -5,6 +5,8 @@ import { assert } from "../../lib/typescript/assert.ts"; import { CardFormStoreInterface } from "./store/card-form-store-interface.ts"; 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"; type Props = { cardFormStore: CardFormStoreInterface; @@ -19,11 +21,29 @@ export const CardFormWrapper = observer((props: Props) => { return ; } - if (cardFormStore.isCardPreviewSelected.value) { + if (cardFormStore.cardInnerScreen.value === "cardPreview") { return ( cardFormStore.cardInnerScreen.onChange(null)} + /> + ); + } + + if (cardFormStore.cardInnerScreen.value === "example") { + return ( + cardFormStore.cardInnerScreen.onChange(null)} + /> + ); + } + + if (cardFormStore.cardInnerScreen.value === "cardType") { + return ( + cardFormStore.cardInnerScreen.onChange(null)} /> ); } diff --git a/src/screens/deck-form/card-list.tsx b/src/screens/deck-form/card-list.tsx index 71afa385..bf541057 100644 --- a/src/screens/deck-form/card-list.tsx +++ b/src/screens/deck-form/card-list.tsx @@ -14,6 +14,7 @@ import { Screen } from "../shared/screen.tsx"; import { removeAllTags } from "../../lib/sanitize-html/remove-all-tags.ts"; import { tapScale } from "../../lib/animations/tap-scale.ts"; import { Flex } from "../../ui/flex.tsx"; +import { CardNumber } from "../../ui/card-number.tsx"; export const CardList = observer(() => { const deckFormStore = useDeckFormStore(); @@ -21,7 +22,7 @@ export const CardList = observer(() => { assert(screen.type === "deckForm"); useBackButton(() => { - deckFormStore.quitCardList(); + deckFormStore.quitInnerScreen(); }); if (!deckFormStore.form) { @@ -96,7 +97,10 @@ export const CardList = observer(() => { ...tapScale, })} > -
{removeAllTags(cardForm.front.value)}
+
+ + {removeAllTags(cardForm.front.value)} +
{removeAllTags(cardForm.back.value)}
diff --git a/src/screens/deck-form/card-type.tsx b/src/screens/deck-form/card-type.tsx new file mode 100644 index 00000000..5f7210b6 --- /dev/null +++ b/src/screens/deck-form/card-type.tsx @@ -0,0 +1,112 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { t } from "../../translations/t.ts"; +import { Flex } from "../../ui/flex.tsx"; +import { css, cx } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { SelectWithChevron } from "../../ui/select-with-chevron.tsx"; +import { HintTransparent } from "../../ui/hint-transparent.tsx"; +import { List } from "../../ui/list.tsx"; +import { action } from "mobx"; +import { CardRow } from "../../ui/card-row.tsx"; +import { CardFormType, createAnswerForm } from "./store/deck-form-store.ts"; +import React from "react"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { + formatCardType, + formatCardTypeDescription, +} from "./format-card-type.ts"; +import { CardAnswerErrors } from "./card-answer-errors.tsx"; + +type Props = { + cardForm: CardFormType; + onBack: () => void; +}; + +export const CardType = observer((props: Props) => { + const { cardForm, onBack } = props; + + useBackButton(() => { + onBack(); + }); + + useMainButton(t("go_back"), () => { + onBack(); + }); + + return ( + + + +
+ {t("card_answer_type")} +
+ { + cardForm?.answerType.onChange(value); + }} + options={[ + { label: formatCardType("remember"), value: "remember" }, + { + label: formatCardType("choice_single"), + value: "choice_single", + }, + ]} + /> +
+ + {formatCardTypeDescription(cardForm.answerType.value)} + +
+ + <> + {cardForm.answerType.value === "choice_single" && ( + <> + { + return { + onClick: action(() => { + cardForm.answerId = answerForm.id; + cardForm.answerFormType = "edit"; + }), + text: answerForm.text.value, + right: answerForm.isCorrect.value ? ( + + ) : null, + }; + })} + /> + + + { + const answerForm = createAnswerForm(); + cardForm.answers.push(answerForm); + cardForm.answerId = answerForm.id; + cardForm.answerFormType = "new"; + })} + > + + {" "} + {t("add_answer")} + + + + )} + +
+ ); +}); diff --git a/src/screens/deck-form/deck-form-screen.tsx b/src/screens/deck-form/deck-form-screen.tsx index 23b25925..74fae7b6 100644 --- a/src/screens/deck-form/deck-form-screen.tsx +++ b/src/screens/deck-form/deck-form-screen.tsx @@ -5,6 +5,7 @@ import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; import { CardList } from "./card-list.tsx"; import { CardFormWrapper } from "./card-form-wrapper.tsx"; import { PreventTelegramSwipeDownClosingIos } from "../../lib/telegram/prevent-telegram-swipe-down-closing.tsx"; +import { SpeakingCards } from "./speaking-cards.tsx"; export const DeckFormScreen = observer(() => { const deckFormStore = useDeckFormStore(); @@ -25,6 +26,14 @@ export const DeckFormScreen = observer(() => { ); } + if (deckFormStore.deckFormScreen === "speakingCards") { + return ( + + + + ); + } + // No PreventTelegramSwipeDownClosingIos because the description textarea usually requires scroll return ; }); diff --git a/src/screens/deck-form/deck-form.tsx b/src/screens/deck-form/deck-form.tsx index e13a90e1..ee08a040 100644 --- a/src/screens/deck-form/deck-form.tsx +++ b/src/screens/deck-form/deck-form.tsx @@ -12,22 +12,20 @@ import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.ts import { assert } from "../../lib/typescript/assert.ts"; import { CardRow } from "../../ui/card-row.tsx"; import { Button } from "../../ui/button.tsx"; -import { HintTransparent } from "../../ui/hint-transparent.tsx"; -import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; -import { Select } from "../../ui/select.tsx"; -import { enumEntries } from "../../lib/typescript/enum-values.ts"; -import { - languageKeyToHuman, - SpeakLanguageEnum, -} from "../../lib/voice-playback/speak.ts"; -import { DeckSpeakFieldEnum } from "../../../functions/db/deck/decks-with-cards-schema.ts"; import { theme } from "../../ui/theme.tsx"; import { t } from "../../translations/t.ts"; import { deckListStore } from "../../store/deck-list-store.ts"; import { reset } from "../../ui/reset.ts"; import { Screen } from "../shared/screen.tsx"; import { CenteredUnstyledButton } from "../../ui/centered-unstyled-button.tsx"; +import { ListHeader } from "../../ui/list-header.tsx"; +import { List } from "../../ui/list.tsx"; +import { FilledIcon } from "../../ui/filled-icon.tsx"; +import { ListRightText } from "../../ui/list-right-text.tsx"; import { Flex } from "../../ui/flex.tsx"; +import { boolNarrow } from "../../lib/typescript/bool-narrow.ts"; +import { isFormValid } from "mobx-form-lite"; +import { userStore } from "../../store/user-store.ts"; export const DeckForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -98,56 +96,77 @@ export const DeckForm = observer(() => { deckFormStore.goToCardList(); }} > - {t("cards")} - {deckFormStore.form.cards.length} + + + {t("cards")} + + + {deckFormStore.form.cards.length} + )} - - {t("speaking_cards")} - - - {deckFormStore.isSpeakingCardEnabled ? ( - -
-
- {t("voice_language")} -
- {deckFormStore.form.speakingCardsLocale.value ? ( - - value={deckFormStore.form.speakingCardsLocale.value} - onChange={deckFormStore.form.speakingCardsLocale.onChange} - options={enumEntries(SpeakLanguageEnum).map(([name, key]) => ({ - value: key, - label: languageKeyToHuman(name), - }))} - /> - ) : null} -
- -
-
- {t("card_speak_side")} -
- {deckFormStore.form.speakingCardsField.value ? ( - - 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") }, - ]} - /> - ) : null} -
-
- ) : ( - <> - {t("card_speak_description")} - + {deckFormStore.form.cards.length > 0 && ( +
+ + + ), + onClick: () => { + deckFormStore.goToSpeakingCards(); + }, + right: ( + + ), + }, + userStore.canUseAiMassGenerate + ? { + text: "Generate cards with AI", + icon: ( + + ), + onClick: () => { + if ( + !deckFormStore.form || + !isFormValid(deckFormStore.form) + ) { + return; + } + assert(screen.deckId, "Deck ID should be defined"); + screenStore.go({ + type: "aiMassCreation", + deckId: screen.deckId, + deckTitle: deckFormStore.form.title.value, + }); + }, + } + : undefined, + ].filter(boolNarrow)} + /> +
)}
diff --git a/src/screens/deck-form/format-card-type.ts b/src/screens/deck-form/format-card-type.ts new file mode 100644 index 00000000..c47f9344 --- /dev/null +++ b/src/screens/deck-form/format-card-type.ts @@ -0,0 +1,24 @@ +import { CardAnswerType } from "../../../functions/db/custom-types.ts"; +import { t } from "../../translations/t.ts"; + +export const formatCardType = (type: CardAnswerType) => { + switch (type) { + case "remember": + return t("yes_no"); + case "choice_single": + return t("answer_type_choice"); + default: + return type satisfies never; + } +}; + +export const formatCardTypeDescription = (type: CardAnswerType) => { + switch (type) { + case "remember": + return t("answer_type_explanation_remember"); + case "choice_single": + return t("answer_type_explanation_choice"); + default: + return type satisfies never; + } +}; diff --git a/src/screens/deck-form/speaking-cards.tsx b/src/screens/deck-form/speaking-cards.tsx new file mode 100644 index 00000000..8ecca35b --- /dev/null +++ b/src/screens/deck-form/speaking-cards.tsx @@ -0,0 +1,86 @@ +import { observer } from "mobx-react-lite"; +import { Screen } from "../shared/screen.tsx"; +import { t } from "../../translations/t.ts"; +import { CardRow } from "../../ui/card-row.tsx"; +import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; +import { Flex } from "../../ui/flex.tsx"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { Select } from "../../ui/select.tsx"; +import { enumEntries } from "../../lib/typescript/enum-values.ts"; +import { + languageKeyToHuman, + SpeakLanguageEnum, +} from "../../lib/voice-playback/speak.ts"; +import { DeckSpeakFieldEnum } from "../../../functions/db/deck/decks-with-cards-schema.ts"; +import { HintTransparent } from "../../ui/hint-transparent.tsx"; +import React from "react"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; + +export const SpeakingCards = observer(() => { + const deckFormStore = useDeckFormStore(); + + if (!deckFormStore.form) { + console.log("SpeakingCards: no deck form"); + return null; + } + + useBackButton(() => { + deckFormStore.quitInnerScreen(); + }); + + useMainButton(t("go_back"), () => { + deckFormStore.quitInnerScreen(); + }); + + return ( + + + {t("speaking_cards")} + + + {t("card_speak_description")} + + {deckFormStore.isSpeakingCardEnabled ? ( + +
+
+ {t("voice_language")} +
+ {deckFormStore.form.speakingCardsLocale.value ? ( + + value={deckFormStore.form.speakingCardsLocale.value} + onChange={deckFormStore.form.speakingCardsLocale.onChange} + options={enumEntries(SpeakLanguageEnum).map(([name, key]) => ({ + value: key, + label: languageKeyToHuman(name), + }))} + /> + ) : null} +
+ +
+
+ {t("card_speak_side")} +
+ {deckFormStore.form.speakingCardsField.value ? ( + + 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") }, + ]} + /> + ) : null} +
+
+ ) : 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 df155f66..a5451976 100644 --- a/src/screens/deck-form/store/card-form-store-interface.ts +++ b/src/screens/deck-form/store/card-form-store-interface.ts @@ -1,12 +1,14 @@ import { CardFormType } from "./deck-form-store.ts"; -import { BooleanToggle, TextField } from "mobx-form-lite"; +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 interface CardFormStoreInterface { cardForm?: CardFormType | null; onSaveCard: () => void; onBackCard: () => void; - isCardPreviewSelected: BooleanToggle; + cardInnerScreen: TextField; isSending: boolean; markCardAsRemoved?: () => void; diff --git a/src/screens/deck-form/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts index f3974886..5ef5fd23 100644 --- a/src/screens/deck-form/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -1,6 +1,5 @@ import { BooleanField, - BooleanToggle, formTouchAll, isFormDirty, isFormEmpty, @@ -28,7 +27,10 @@ import { SpeakLanguageEnum } from "../../../lib/voice-playback/speak.ts"; import { t } from "../../../translations/t.ts"; import { CardAnswerType } from "../../../../functions/db/custom-types.ts"; import { v4 } from "uuid"; -import { CardFormStoreInterface } from "./card-form-store-interface.ts"; +import { + CardFormStoreInterface, + CardInnerScreenType, +} from "./card-form-store-interface.ts"; import { UpsertDeckRequest } from "../../../../functions/upsert-deck.ts"; import { UnwrapArray } from "../../../lib/typescript/unwrap-array.ts"; @@ -168,10 +170,10 @@ export type CardFilterDirection = "desc" | "asc"; export class DeckFormStore implements CardFormStoreInterface { cardFormIndex?: number; cardFormType?: "new" | "edit"; - isCardPreviewSelected = new BooleanToggle(false); form?: DeckFormType; isSending = false; - isCardList = false; + cardInnerScreen = new TextField(null); + deckInnerScreen?: "cardList" | "speakingCards"; cardFilter = { text: new TextField(""), sortBy: new TextField("createdAt"), @@ -186,8 +188,8 @@ export class DeckFormStore implements CardFormStoreInterface { if (this.cardFormIndex !== undefined) { return "cardForm"; } - if (this.isCardList) { - return "cardList"; + if (this.deckInnerScreen) { + return this.deckInnerScreen; } return "deckForm"; } @@ -217,6 +219,13 @@ export class DeckFormStore implements CardFormStoreInterface { } } + goToSpeakingCards() { + if (!this.form || !isFormValid(this.form)) { + return; + } + this.deckInnerScreen = "speakingCards"; + } + goToCardList() { if (!this.form) { return; @@ -225,11 +234,11 @@ export class DeckFormStore implements CardFormStoreInterface { formTouchAll(this.form); return; } - this.isCardList = true; + this.deckInnerScreen = "cardList"; } - quitCardList() { - this.isCardList = false; + quitInnerScreen() { + this.deckInnerScreen = undefined; } get filteredCards() { @@ -468,7 +477,7 @@ export class DeckFormStore implements CardFormStoreInterface { this.onDeckSave() .then( action(() => { - this.isCardList = true; + this.deckInnerScreen = "cardList"; this.cardFormIndex = undefined; this.cardFormType = undefined; }), 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 86a53391..398db419 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 @@ -6,7 +6,6 @@ import { } from "./deck-form-store.ts"; import { action, makeAutoObservable } from "mobx"; import { - BooleanToggle, formTouchAll, isFormDirty, isFormEmpty, @@ -20,7 +19,10 @@ import { assert } from "../../../lib/typescript/assert.ts"; import { AddCardRequest } from "../../../../functions/add-card.ts"; import { deckListStore } from "../../../store/deck-list-store.ts"; import { t } from "../../../translations/t.ts"; -import { CardFormStoreInterface } from "./card-form-store-interface.ts"; +import { + CardFormStoreInterface, + CardInnerScreenType, +} from "./card-form-store-interface.ts"; import { DeckSpeakFieldEnum } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; export class QuickAddCardFormStore implements CardFormStoreInterface { @@ -33,7 +35,7 @@ export class QuickAddCardFormStore implements CardFormStoreInterface { answers: createAnswerListField([], () => this.cardForm), }; isSending = false; - isCardPreviewSelected = new BooleanToggle(false); + cardInnerScreen = new TextField(null); constructor( public form?: { diff --git a/src/screens/freeze-cards/freeze-cards-screen.tsx b/src/screens/freeze-cards/freeze-cards-screen.tsx index 2d843d19..feb2e389 100644 --- a/src/screens/freeze-cards/freeze-cards-screen.tsx +++ b/src/screens/freeze-cards/freeze-cards-screen.tsx @@ -41,7 +41,7 @@ export const FreezeCardsScreen = observer(() => { backgroundColor={theme.icons.turquoise} icon={"mdi-snowflake"} /> - {t("freeze_how")} + {t("how")} } body={ diff --git a/src/screens/plans/plan-item.tsx b/src/screens/plans/plan-item.tsx index 41160e6c..ff510dd7 100644 --- a/src/screens/plans/plan-item.tsx +++ b/src/screens/plans/plan-item.tsx @@ -4,9 +4,11 @@ import { Flex } from "../../ui/flex.tsx"; import React from "react"; import { HorizontalDivider } from "../../ui/horizontal-divider.tsx"; import { t } from "../../translations/t.ts"; +import { reset } from "../../ui/reset.ts"; type Props = { title: string; + price: string; description?: string[]; onClick?: () => void; isSelected?: boolean; @@ -14,7 +16,7 @@ type Props = { }; export const PlanItem = (props: Props) => { - const { title, description, onClick, isSelected, paidUntil } = props; + const { title, description, onClick, isSelected, paidUntil, price } = props; return (
{ }), isSelected && css({ - border: "4px solid " + theme.buttonColor, + border: `2px solid ${theme.buttonColor}`, borderRadius: theme.borderRadius, }), )} > - -

{title}

+ +

+ {title} +

+

+ {price} +

{description && (
    {description.map((item, i) => ( -
  • {item}
  • +
  • + {" "} + {item} +
  • ))}
)} diff --git a/src/screens/plans/plans-screen.tsx b/src/screens/plans/plans-screen.tsx index 9be753ec..db66eef4 100644 --- a/src/screens/plans/plans-screen.tsx +++ b/src/screens/plans/plans-screen.tsx @@ -9,7 +9,11 @@ import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { Hint } from "../../ui/hint.tsx"; import { useMount } from "../../lib/react/use-mount.ts"; import { FullScreenLoader } from "../../ui/full-screen-loader.tsx"; -import { getPlanDescription, getPlanFullTile } from "./translations.ts"; +import { + getPlanDescription, + getPlanFullPrice, + getPlanTitle, +} from "./translations.ts"; import { PlansScreenStore } from "./store/plans-screen-store.ts"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; import { userStore } from "../../store/user-store.ts"; @@ -35,9 +39,9 @@ export const PlansScreen = observer(() => { () => store.isBuyButtonVisible, ); - useTelegramProgress(() => store.isCreatingOrder); + useTelegramProgress(() => store.createOrderRequest.isLoading); - if (store.plansRequest?.state === "pending") { + if (store.plansRequest.result.status === "loading") { return ; } @@ -64,7 +68,8 @@ export const PlansScreen = observer(() => { return ( ; - isCreatingOrder = false; + plansRequest = new RequestStore(allPlansRequest); + createOrderRequest = new RequestStore(createOrderRequest); selectedPlanId: number | null = null; constructor() { @@ -19,12 +17,12 @@ export class PlansScreenStore { } load() { - this.plansRequest = fromPromise(allPlansRequest()); + this.plansRequest.execute(); } get plans() { - return this.plansRequest?.state === "fulfilled" - ? this.plansRequest.value.plans + return this.plansRequest.result.status === "success" + ? this.plansRequest.result.data.plans : []; } @@ -49,18 +47,18 @@ export class PlansScreenStore { return getBuyText(selectedPlan); } - createOrder() { + async createOrder() { assert(this.selectedPlanId !== null); - this.isCreatingOrder = true; - createOrderRequest(this.selectedPlanId) - .then((response) => { - WebApp.openTelegramLink(response.payLink); - }) - .finally( - action(() => { - this.isCreatingOrder = false; - }), - ); + const result = await this.createOrderRequest.execute(this.selectedPlanId); + if (result.status !== "success") { + notifyError(t("error_try_again"), { + info: "Order creation failed", + plan: this.selectedPlanId, + }); + return; + } + + WebApp.openTelegramLink(result.data.payLink); } } diff --git a/src/screens/plans/translations.ts b/src/screens/plans/translations.ts index 2bd44e07..bf3b8933 100644 --- a/src/screens/plans/translations.ts +++ b/src/screens/plans/translations.ts @@ -20,13 +20,13 @@ export const getPlanDescription = (plan: PlanDb) => { case "plus": switch (lang) { case "en": - return ["Duplicate folder, deck", "Priority support"]; + return ["Duplicate folder, deck"]; case "ru": - return ["Дублирование папок, колод", "Приоритетная поддержка"]; + return ["Дублирование папок, колод"]; case "es": - return ["Duplicar carpeta, baraja", "Soporte prioritario"]; + return ["Duplicar carpeta, baraja"]; case "pt-br": - return ["Duplicar pasta, baralho", "Suporte prioritário"]; + return ["Duplicar pasta, baralho"]; default: return lang satisfies never; } @@ -34,6 +34,7 @@ export const getPlanDescription = (plan: PlanDb) => { switch (lang) { case "en": return [ + "Card mass creation tools using AI", "Duplicate folder, deck", "One time deck links and one time folder links", "Specify deck and folder access duration", @@ -41,6 +42,7 @@ export const getPlanDescription = (plan: PlanDb) => { ]; case "ru": return [ + "Инструменты массового создания карточек с использованием ИИ", "Дублирование папок, колод", "Одноразовые ссылки на колоды и папки", "Указание длительность доступа к колодам и папкам", @@ -48,6 +50,7 @@ export const getPlanDescription = (plan: PlanDb) => { ]; case "es": return [ + "Herramientas de creación masiva de tarjetas utilizando IA", "Duplicar carpeta, baraja", "Enlaces de carpeta y baraja de un solo uso", "Especificar la duración del acceso a la carpeta y la baraja", @@ -55,6 +58,7 @@ export const getPlanDescription = (plan: PlanDb) => { ]; case "pt-br": return [ + "Ferramentas de criação em massa de cartões usando IA", "Duplicar pasta, baralho", "Links de pasta e baralho de uso único", "Especificar a duração do acesso à pasta e ao baralho", @@ -70,17 +74,17 @@ export const getPlanDescription = (plan: PlanDb) => { } }; -export const getPlanFullTile = (plan: PlanDb) => { +export const getPlanFullPrice = (plan: PlanDb) => { const lang = translator.getLang(); switch (lang) { case "en": - return `${getPlanTitle(plan)} (${formatPlanPrice(plan)}/mo.)`; + return `${formatPlanPrice(plan)}/mo.`; case "ru": - return `${getPlanTitle(plan)} (${formatPlanPrice(plan)}/мес.)`; + return `${formatPlanPrice(plan)}/мес.`; case "es": - return `${getPlanTitle(plan)} (${formatPlanPrice(plan)}/mes)`; + return `${formatPlanPrice(plan)}/mes`; case "pt-br": - return `${getPlanTitle(plan)} (${formatPlanPrice(plan)}/mês)`; + return `${formatPlanPrice(plan)}/mês`; default: return lang satisfies never; } diff --git a/src/screens/shared/snackbar.tsx b/src/screens/shared/snackbar.tsx new file mode 100644 index 00000000..7eecbed4 --- /dev/null +++ b/src/screens/shared/snackbar.tsx @@ -0,0 +1,92 @@ +import { + closeSnackbar, + enqueueSnackbar, + type SnackbarKey, + SnackbarProvider, +} from "notistack"; +import { css, cx } from "@emotion/css"; +import { reset } from "../../ui/reset.ts"; +import { theme } from "../../ui/theme.tsx"; +import { reportHandledError } from "../../lib/rollbar/rollbar.tsx"; +import { userStore } from "../../store/user-store.ts"; + +const ClearSnackbar = (props: { snackbarId: SnackbarKey }) => { + const { snackbarId } = props; + return ( + + ); +}; + +const sharedStyles = { + borderRadius: theme.borderRadius, + backgroundColor: "rgba(56, 56, 56, 0.85)", + boxShadow: theme.boxShadow, +}; + +export const notifySuccess = (message: string) => { + enqueueSnackbar(message, { + variant: "success", + action: (snackbarId) => , + style: sharedStyles, + autoHideDuration: 3000, + }); +}; + +export const notifyError = (message: string, report?: any) => { + enqueueSnackbar(message, { + variant: "error", + action: (snackbarId) => , + style: sharedStyles, + autoHideDuration: 4000, + }); + + if (report) { + report.userId = userStore.user?.id; + reportHandledError(message, report); + } +}; + +export const SnackbarProviderWrapper = () => { + return ( + + +
+ ), + error: ( +
+ +
+ ), + }} + /> + ); +}; diff --git a/src/screens/user-settings/store/user-settings-store.tsx b/src/screens/user-settings/store/user-settings-store.tsx index 0a03648d..dedb0e6f 100644 --- a/src/screens/user-settings/store/user-settings-store.tsx +++ b/src/screens/user-settings/store/user-settings-store.tsx @@ -9,10 +9,12 @@ import { assert } from "../../../lib/typescript/assert.ts"; import { DateTime } from "luxon"; import { formatTime } from "../generate-time-range.tsx"; import { userSettingsRequest } from "../../../api/api.ts"; -import { screenStore } from "../../../store/screen-store.ts"; import { UserSettingsRequest } from "../../../../functions/user-settings.ts"; import { userStore } from "../../../store/user-store.ts"; import { hapticNotification } from "../../../lib/telegram/haptics.ts"; +import { RequestStore } from "../../../lib/mobx-request/requestStore.ts"; +import { notifyError, notifySuccess } from "../../shared/snackbar.tsx"; +import { t } from "../../../translations/t.ts"; const DEFAULT_TIME = "12:00"; @@ -22,7 +24,7 @@ export class UserSettingsStore { isSpeakingCardsEnabled: BooleanField; time: TextField; }; - isSending = false; + userSettingsRequest = new RequestStore(userSettingsRequest); constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -52,15 +54,13 @@ export class UserSettingsStore { ); } - submit() { + async submit() { assert(this.form); if (!isFormValid(this.form)) { formTouchAll(this.form); return; } - this.isSending = true; - const [hour, minute] = this.form.time.value.split(":"); const body: UserSettingsRequest = { @@ -76,20 +76,21 @@ export class UserSettingsStore { .toString(), }; - userSettingsRequest(body) - .then(() => { - hapticNotification("success"); - userStore.updateSettings({ - is_remind_enabled: body.isRemindNotifyEnabled, - last_reminded_date: body.remindNotificationTime, - is_speaking_card_enabled: body.isSpeakingCardEnabled, - }); - screenStore.go({ type: "main" }); - }) - .finally( - action(() => { - this.isSending = false; - }), - ); + const result = await this.userSettingsRequest.execute(body); + + if (result.status === "error") { + notifyError(t("error_try_again"), { + info: "Error updating user settings", + }); + return; + } + + hapticNotification("success"); + notifySuccess(t("user_settings_updated")); + userStore.updateSettings({ + is_remind_enabled: body.isRemindNotifyEnabled, + last_reminded_date: body.remindNotificationTime, + is_speaking_card_enabled: body.isSpeakingCardEnabled, + }); } } diff --git a/src/screens/user-settings/user-settings-screen.tsx b/src/screens/user-settings/user-settings-screen.tsx index 3353da59..2aa947f7 100644 --- a/src/screens/user-settings/user-settings-screen.tsx +++ b/src/screens/user-settings/user-settings-screen.tsx @@ -34,7 +34,7 @@ export const UserSettingsScreen = observer(() => { useBackButton(() => { screenStore.back(); }); - useTelegramProgress(() => userSettingsStore.isSending); + useTelegramProgress(() => userSettingsStore.userSettingsRequest.isLoading); if (!deckListStore.myInfo || !userSettingsStore.form) { return null; diff --git a/src/store/screen-store.ts b/src/store/screen-store.ts index 75d5f2b9..43c147a8 100644 --- a/src/store/screen-store.ts +++ b/src/store/screen-store.ts @@ -15,6 +15,7 @@ type Route = | { type: "deckCatalog" } | { type: "shareDeck"; deckId: number; shareId: string } | { type: "shareFolder"; folderId: number; shareId: string } + | { type: "aiMassCreation"; deckId: number; deckTitle: string | null } | { type: "plans" } | { type: "componentCatalog" } | { type: "freezeCards" } diff --git a/src/store/user-store.ts b/src/store/user-store.ts index 7cdd8812..7b5b4a4f 100644 --- a/src/store/user-store.ts +++ b/src/store/user-store.ts @@ -5,6 +5,7 @@ import { type PlansForUser } from "../../functions/db/plan/get-active-plans-for- import { BooleanToggle } from "mobx-form-lite"; import { persistableField } from "../lib/mobx-form-lite-persistable/persistable-field.ts"; import { canAdvancedShare } from "../../shared/access/can-advanced-share.ts"; +import { canUseAiMassGenerate } from "../../shared/access/can-use-ai-mass-generate.ts"; export class UserStore { userInfo?: UserDbType; @@ -54,6 +55,14 @@ export class UserStore { assert(this.userInfo, "myInfo is not loaded in optimisticUpdateSettings"); Object.assign(this.userInfo, body); } + + get canUseAiMassGenerate() { + const user = this.user; + if (!user) { + return false; + } + return canUseAiMassGenerate(user, userStore.plans); + } } export const userStore = new UserStore(); diff --git a/src/translations/t.ts b/src/translations/t.ts index 495b42ad..b45dea13 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -84,7 +84,6 @@ const en = { card_sort_by_date: "Date", card_sort_by_front: "Front", card_sort_by_back: "Back", - card_answer_back: "Back", sort_by: "Sort by", title: "Title", description: "Description", @@ -94,7 +93,7 @@ const en = { card_speak_side_front: "Front", card_speak_side_back: "Back", card_speak_description: - "Play spoken audio for each flashcard to enhance pronunciation", + "Play spoken audio for each flashcard to listen to the pronunciation", review_deck_finished: `You have finished this deck for now 🎉`, review_all_cards: `You have repeated all the cards for today 🎉`, review_finished_want_more: "Want more? You have", @@ -167,16 +166,16 @@ const en = { payment_pp: "Privacy Policy", share_no_links_for_folder: "You haven't created any one-time links for this folder", - go_back: "Go back", + go_back: "Back", validation_at_least_one_deck: "Please select at least 1 deck", add_answer: "Add quiz answer", edit_answer: "Edit answer", answer_text: "Answer text", is_correct: "Is correct", is_correct_explanation: `There can only be one correct answer`, - card_advanced: "Advanced", + advanced: "Advanced", review_idk: "I don't know", - answer: "Card type", + card_answer_type: "Card type", yes_no: "Remember", answer_type_choice: "Quiz", answer_type_explanation_remember: `A card with "Remember" and "Don't remember" buttons`, @@ -207,7 +206,7 @@ const en = { "Are you sure you want to freeze your cards? This action can't be undone.", freeze_error: "Failed to freeze cards. Please try again later", freeze_title: "Freeze cards", - freeze_how: "How it works", + how: "How it works", freeze_how_title: `When you freeze cards, they will be postponed for the selected number of days, allowing you to take a break. Use it on holidays or whenever you need a rest.`, freeze_rule_1: "All your cards will be paused, and you won't receive any notifications.", @@ -221,17 +220,23 @@ const en = { freeze_notified: "You'll get notified on", freeze_hint: "Postpone studying cards", ui_loading: "Loading...", + is_on: "On", + is_off: "Off", + error_try_again: "An error occurred. We're solving the issue", + user_settings_updated: "Settings have been updated", }; type Translation = typeof en; const ru: Translation = { + user_settings_updated: "Настройки обновлены", + is_on: "Включено", + is_off: "Выключено", folder_form_no_decks: "В папке нет колод", card_next: "Следующая", card_previous: "Предыдущая", deck_form_remove_warning: "Колода должна содержать хотя бы одну карточку", folder_form_quit_card_confirm: "Выйти без сохранения папки?", - card_answer_back: "Назад", read_more: "Читать далее", profile_section: "Профиль", review_wrong_label: "Неправильно", @@ -268,7 +273,7 @@ const ru: Translation = { validation_answer_at_least_one_correct: "Выберите хотя бы 1 правильный ответ", add_answer: "Добавить ответ", answer_text: "Текст ответа", - card_advanced: "Дополнительно", + advanced: "Дополнительно", edit_answer: "Редактировать ответ", is_correct: "Правильный", validation_at_least_one_answer_required: "Укажите хотя бы 1 ответ", @@ -344,7 +349,7 @@ const ru: Translation = { card_speak_side: "Сторона карточки", card_speak_side_front: "Лицевая", card_speak_side_back: "Обратная", - card_speak_description: "Позволяет улучшить произношение", + card_speak_description: "Позволяет услышать произношение", review_deck_finished: `Вы прошли эту колоду 🎉`, review_all_cards: `Вы повторили все карточки на сегодня 🎉`, review_finished_want_more: "Хотите ещё? У вас есть", @@ -416,7 +421,7 @@ const ru: Translation = { "Как распределяется ваше время между изучением нового материала и повторением", user_stats_chart_min_expl: "Не помню карточку", user_stats_chart_max_expl: "Знаю карточку очень хорошо", - answer: "Тип карточки", + card_answer_type: "Тип карточки", yes_no: "Помню", answer_type_choice: "Тест", answer_type_explanation_remember: `Карточка с кнопками "Помню" и "Не помню"`, @@ -425,7 +430,7 @@ const ru: Translation = { freeze_rule_1: "Все ваши карточки будут приостановлены, и вы не будете получать уведомлений.", freeze_for: "Заморозить на", - freeze_how: "Как это работает", + how: "Как это работает", freeze_confirm_freeze: "Вы уверены, что хотите заморозить карточки? Это действие нельзя отменить.", freeze_title: "Заморозить карточки", @@ -441,22 +446,25 @@ const ru: Translation = { validate_under_100: "Пожалуйста, введите число меньше 100", freeze_hint: "Отложите изучение карточек", ui_loading: "Загрузка...", + error_try_again: "Произошла ошибка. Мы решаем проблему", }; const es: Translation = { + user_settings_updated: "Configuración actualizada", + is_off: "Apagado", + is_on: "Encendido", folder_form_no_decks: "No hay mazos en la carpeta", card_next: "Siguiente", card_previous: "Anterior", ui_loading: "Cargando...", yes_no: "Recordar", - answer: "Tipo de tarjeta", + card_answer_type: "Tipo de tarjeta", answer_type_explanation_choice: `Una tarjeta con opciones de respuesta`, answer_type_explanation_remember: `Una tarjeta con botones "Recordar" y "No recordar"`, answer_type_choice: "Elección", profile_section: "Perfil", folder_form_quit_card_confirm: "¿Salir sin guardar la carpeta?", deck_form_remove_warning: "La baraja debe tener al menos una tarjeta", - card_answer_back: "Atrás", read_more: "Leer más", review_idk: "No sé", review_wrong_label: "Incorrecto", @@ -495,7 +503,7 @@ const es: Translation = { validation_answer_at_least_one_correct: "Se debe seleccionar al menos una respuesta correcta", edit_answer: "Editar respuesta", - card_advanced: "Avanzado", + advanced: "Avanzado", answer_text: "Texto de la respuesta", add_answer: "Añadir respuesta", is_correct_explanation: `Solo puede haber una respuesta correcta`, @@ -573,7 +581,7 @@ const es: Translation = { card_speak_side_front: "Frente", card_speak_side_back: "Dorso", card_speak_description: - "Reproducir audio hablado para cada tarjeta y mejorar la pronunciación", + "Reproducir audio hablado para cada tarjeta de memoria para escuchar la pronunciación.", review_deck_finished: `Has terminado este mazo por ahora 🎉`, review_all_cards: `Has repasado todas las tarjetas por hoy 🎉`, review_finished_want_more: "¿Quieres más? Tienes", @@ -653,7 +661,7 @@ const es: Translation = { "No se pudieron congelar las tarjetas. Por favor, inténtalo de nuevo más tarde", freeze_for: "Congelar durante", freeze_for_or_manual: "o escribe manualmente", - freeze_how: "Cómo funciona", + how: "Cómo funciona", freeze_rule_1: "Todas tus tarjetas se pausarán y no recibirás notificaciones.", freeze_rule_2: @@ -665,22 +673,26 @@ const es: Translation = { freeze_rule_3: "La congelación de tarjetas no se puede deshacer.", freeze_title: "Congelar tarjetas", freeze_hint: "Posponer el estudio de las tarjetas", + error_try_again: "Ocurrió un error. Estamos resolviendo el problema", }; const ptBr: Translation = { + user_settings_updated: "Configurações atualizadas", + error_try_again: "Ocorreu um erro. Estamos resolvendo o problema", + is_on: "Ligado", + is_off: "Desligado", folder_form_no_decks: "Não há baralhos na pasta", card_next: "Próxima", card_previous: "Anterior", ui_loading: "Carregando...", yes_no: "Lembrar", - answer: "Tipo de cartão", + card_answer_type: "Tipo de cartão", answer_type_explanation_choice: `Um cartão com opções de resposta`, answer_type_explanation_remember: `Um cartão com botões "Lembrar" e "Não lembrar"`, answer_type_choice: "Escolha", profile_section: "Perfil", folder_form_quit_card_confirm: "Sair sem salvar a pasta?", deck_form_remove_warning: "O baralho deve ter pelo menos um cartão", - card_answer_back: "Costas", read_more: "Leia mais", review_idk: "Não sei", review_wrong_label: "Incorreto", @@ -717,7 +729,7 @@ const ptBr: Translation = { duplicate_folder_confirm: "Tem certeza de que deseja duplicar esta pasta?", add_answer: "Adicionar resposta", answer_text: "Texto da resposta", - card_advanced: "Avançado", + advanced: "Avançado", edit_answer: "Editar resposta", is_correct: "É correto", validation_at_least_one_answer_required: @@ -796,7 +808,7 @@ const ptBr: Translation = { card_speak_side_front: "Frente", card_speak_side_back: "Verso", card_speak_description: - "Reproduzir áudio falado para cada cartão para melhorar a pronúncia", + "Reproduzir áudio falado para cada flashcard para ouvir a pronúncia.", review_deck_finished: `Parabéns! Você terminou este baralho por enquanto. 🎉`, review_all_cards: `Você revisou todos os cartões para hoje 🎉`, review_finished_want_more: "Quer mais? Você tem", @@ -879,7 +891,7 @@ const ptBr: Translation = { freeze_rule_1: "Todos os seus cartões serão pausados e você não receberá notificações.", freeze_how_title: `Quando você congela seus cartões, eles serão adiados pelo número de dias selecionado, permitindo que você descanse. Use isso nas férias ou quando precisar de uma pausa.`, - freeze_how: "Como funciona", + how: "Como funciona", freeze_for: "Congelar por", freeze_for_or_manual: "ou digite manualmente", freeze_error: diff --git a/src/ui/button-side-aligned.tsx b/src/ui/button-side-aligned.tsx index eacdf4f5..c794dcd0 100644 --- a/src/ui/button-side-aligned.tsx +++ b/src/ui/button-side-aligned.tsx @@ -41,7 +41,7 @@ export const ButtonSideAligned = (props: Props) => { backgroundColor: mainColor, cursor: "pointer", ":disabled": { - backgroundColor: parsedColor.lighten(0.15).toHex(), + opacity: 0.4, cursor: "not-allowed", }, color: theme.buttonTextColorComputed, @@ -55,7 +55,7 @@ export const ButtonSideAligned = (props: Props) => { position: "relative", transitionTimingFunction: "ease-in-out", transitionProperty: "background-color, border, box-shadow, color", - ":active": { + ":active:not(:disabled)": { transform: "scale(0.97)", }, }), diff --git a/src/ui/card-number.tsx b/src/ui/card-number.tsx new file mode 100644 index 00000000..ff5bd054 --- /dev/null +++ b/src/ui/card-number.tsx @@ -0,0 +1,19 @@ +import { css } from "@emotion/css"; +import { theme } from "./theme.tsx"; +import React from "react"; + +type Props = { number: number }; + +export const CardNumber = (props: Props) => { + const { number } = props; + + return ( + + {number}.{" "} + + ); +}; diff --git a/src/ui/input.tsx b/src/ui/input.tsx index 03891286..7197d481 100644 --- a/src/ui/input.tsx +++ b/src/ui/input.tsx @@ -11,12 +11,13 @@ interface Props { placeholder?: string; type?: "input" | "textarea"; field: TextField; + isDisabled?: boolean; rows?: number; icon?: string; } export const Input = observer((props: Props) => { - const { field, placeholder, type, rows, icon } = props; + const { field, placeholder, type, rows, icon, isDisabled } = props; const { onChange, value, isTouched, error, onBlur } = field; const inputRef = useRef(null); @@ -57,11 +58,16 @@ export const Input = observer((props: Props) => { borderRadius: theme.borderRadius, backgroundColor: theme.bgColor, transition: "border-color 0.3s", + ":disabled": { + opacity: 0.4, + cursor: "not-allowed", + }, ":focus": { borderColor: isTouched && error ? theme.danger : theme.buttonColor, outline: "none", }, })} + disabled={isDisabled} type="text" rows={rows} value={value} diff --git a/src/ui/list-header.tsx b/src/ui/list-header.tsx index fd239cae..e5b6eef9 100644 --- a/src/ui/list-header.tsx +++ b/src/ui/list-header.tsx @@ -20,7 +20,7 @@ export const ListHeader = (props: Props) => { paddingTop: 4, paddingBottom: 0, marginBottom: 4, - position: "relative", + position: rightSlot ? "relative" : undefined, color: theme.hintColor, textTransform: "uppercase", })} diff --git a/src/ui/list-right-text.tsx b/src/ui/list-right-text.tsx new file mode 100644 index 00000000..0822aabc --- /dev/null +++ b/src/ui/list-right-text.tsx @@ -0,0 +1,24 @@ +import { css } from "@emotion/css"; +import { theme } from "./theme.tsx"; +import React from "react"; + +type ListRightTextParams = { + text: string; + cut?: boolean; +}; + +export const ListRightText = (props: ListRightTextParams) => { + const { text, cut } = props; + if (!text) { + return null; + } + + const textFormatted = + text.length > 10 && cut ? `${text.slice(0, 10)}...` : text; + + return ( +
+ {textFormatted} +
+ ); +}; diff --git a/src/ui/list.tsx b/src/ui/list.tsx index f2e98186..40531c6b 100644 --- a/src/ui/list.tsx +++ b/src/ui/list.tsx @@ -5,7 +5,7 @@ import { tapScale } from "../lib/animations/tap-scale.ts"; import React, { ReactNode } from "react"; export type ListItemType = { - text: string; + text: ReactNode; isLinkColor?: boolean; onClick?: () => void; icon?: ReactNode; diff --git a/src/ui/wysiwyg-field/wysiwig-field.tsx b/src/ui/wysiwyg-field/wysiwig-field.tsx index 66199522..2e2b9f49 100644 --- a/src/ui/wysiwyg-field/wysiwig-field.tsx +++ b/src/ui/wysiwyg-field/wysiwig-field.tsx @@ -79,7 +79,11 @@ export const BtnRed = createButton( export const BtnClearFormatting = createButton( t("wysiwyg_clear_formatting"), , - "removeFormat", + () => { + document.execCommand("removeFormat", false); + // A hack is used since removeFormat doesn't support clearing H1-H6 tags + document.execCommand("formatBlock", false, "div"); + }, ); type Props = {