diff --git a/src/lib/mobx-form/persistable-field.ts b/src/lib/mobx-form/persistable-field.ts index 980a816e..37637646 100644 --- a/src/lib/mobx-form/persistable-field.ts +++ b/src/lib/mobx-form/persistable-field.ts @@ -4,12 +4,13 @@ import { makePersistable } from "mobx-persist-store"; export const persistableField = ( field: TextField, storageKey: string, + expireIn?: number, ): TextField => { makePersistable(field, { name: storageKey, properties: ["value"], storage: window.localStorage, - expireIn: 86400000, // One day in milliseconds + expireIn: expireIn, }); return field; diff --git a/src/lib/mobx-form/validator.ts b/src/lib/mobx-form/validator.ts index b68ce38c..3eec800f 100644 --- a/src/lib/mobx-form/validator.ts +++ b/src/lib/mobx-form/validator.ts @@ -1,12 +1,13 @@ // @ts-nocheck -// https://codesandbox.io/s/github/final-form/react-final-form/tree/master/examples/field-level-validation?file=/index.js +import { t } from "../../translations/t.ts"; +// https://codesandbox.io/s/github/final-form/react-final-form/tree/master/examples/field-level-validation?file=/index.js export const validators = { required: - (errorMessage = "Required") => + (errorMessage = t("validation_required")) => (value) => value ? undefined : errorMessage, - number: (value) => (isNaN(value) ? "Must be a number" : undefined), + number: (value) => (isNaN(value) ? t("validation_number") : undefined), all: (...validators) => (value) => diff --git a/src/lib/translator/translator.test.ts b/src/lib/translator/translator.test.ts new file mode 100644 index 00000000..1136b0c6 --- /dev/null +++ b/src/lib/translator/translator.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "vitest"; +import { Translator } from "./translator"; + +test("Translator - allows to set and retrieve current lang", () => { + type Translation = { + hello: string; + }; + const en: Translation = { + hello: "Hello!", + }; + const ru: Translation = { + hello: "Привет", + }; + + const translations = { en, ru }; + type Language = keyof typeof translations; + + const ts = new Translator(translations, "en"); + + expect(ts.getLang()).toEqual("en"); + expect(ts.translate("hello")).toBe("Hello!"); + + ts.setLang("ru"); + + expect(ts.getLang()).toEqual("ru"); + expect(ts.translate("hello")).toBe("Привет"); +}); diff --git a/src/lib/translator/translator.ts b/src/lib/translator/translator.ts new file mode 100644 index 00000000..0178a112 --- /dev/null +++ b/src/lib/translator/translator.ts @@ -0,0 +1,27 @@ +type Storage = { + [key in Language]: Resource; +}; + +type DefaultResource = { [key in string]: string }; + +export class Translator< + Language extends string, + Translation extends DefaultResource, +> { + constructor( + private storage: Storage, + private lang: Language, + ) {} + + setLang(lang: Language) { + this.lang = lang; + } + + getLang() { + return this.lang; + } + + translate(key: keyof Translation, defaultValue?: string): string { + return this.storage[this.lang][key] ?? defaultValue; + } +} diff --git a/src/screens/deck-catalog/deck-added-label.tsx b/src/screens/deck-catalog/deck-added-label.tsx index c8a0d227..ddc154ed 100644 --- a/src/screens/deck-catalog/deck-added-label.tsx +++ b/src/screens/deck-catalog/deck-added-label.tsx @@ -1,11 +1,12 @@ import { css, cx } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; import React from "react"; +import { t } from "../../translations/t.ts"; export const DeckAddedLabel = () => { return (
{ const store = useDeckCatalogStore(); @@ -37,31 +41,20 @@ export const DeckCatalog = observer(() => { marginBottom: 16, })} > -

Deck Catalog

-
-
Available in
- - value={store.filters.language.value} - onChange={store.filters.language.onChange} - options={enumEntries(LanguageFilter).map(([name, key]) => ({ - value: key, - label: name === "Any" ? "Any language" : camelCaseToHuman(name), - }))} - /> -
+

{t("deck_catalog")}

-
Category
+
{t("category")}
- The prompt or question you'll see + {t("card_front_side_hint")} -
); diff --git a/src/screens/deck-form/card-form.tsx b/src/screens/deck-form/card-form.tsx index 701e23f5..7cefa6a5 100644 --- a/src/screens/deck-form/card-form.tsx +++ b/src/screens/deck-form/card-form.tsx @@ -6,6 +6,7 @@ import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { CardFormView } from "./card-form-view.tsx"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; +import { t } from "../../translations/t.ts"; export const CardForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -13,7 +14,7 @@ export const CardForm = observer(() => { assert(cardForm, "Card should not be empty before editing"); useMainButton( - "Save", + t("save"), () => { deckFormStore.saveCardForm(); }, diff --git a/src/screens/deck-form/card-list.tsx b/src/screens/deck-form/card-list.tsx index 0c3aa16b..528ce73b 100644 --- a/src/screens/deck-form/card-list.tsx +++ b/src/screens/deck-form/card-list.tsx @@ -9,6 +9,7 @@ import { theme } from "../../ui/theme.tsx"; import { Button } from "../../ui/button.tsx"; import React from "react"; import { reset } from "../../ui/reset.ts"; +import { t } from "../../translations/t.ts"; export const CardList = observer(() => { const deckFormStore = useDeckFormStore(); @@ -32,12 +33,12 @@ export const CardList = observer(() => { marginBottom: 16, })} > -

Cards

+

{t("cards")}

{deckFormStore.form.cards.length > 1 && ( <>
{ gap: 8, })} > - Sort by + {t("sort_by")} {[ { - label: "Date", + label: t("card_sort_by_date"), fieldName: "createdAt" as const, }, { - label: "Front", + label: t("card_sort_by_front"), fieldName: "frontAlpha" as const, }, { - label: "Back", + label: t("card_sort_by_back"), fieldName: "backAlpha" as const, }, ].map((item, i) => { @@ -111,7 +112,7 @@ export const CardList = observer(() => { deckFormStore.openNewCardForm(); }} > - Add card + {t("add_card")}
); diff --git a/src/screens/deck-form/deck-form.tsx b/src/screens/deck-form/deck-form.tsx index c7c07bba..08aaf3cd 100644 --- a/src/screens/deck-form/deck-form.tsx +++ b/src/screens/deck-form/deck-form.tsx @@ -22,6 +22,7 @@ import { } 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"; export const DeckForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -32,7 +33,7 @@ export const DeckForm = observer(() => { deckFormStore.loadForm(); }); useMainButton( - "Save", + t("save"), () => { deckFormStore.onDeckSave(); }, @@ -58,13 +59,13 @@ export const DeckForm = observer(() => { })} >

- {screen.deckId ? "Edit deck" : "Add deck"} + {screen.deckId ? t("edit_deck") : t("add_deck")}

-
- +
{ {" "} - Explore more decks + {t("explore_public_decks")} ) : null} @@ -139,7 +137,7 @@ export const MainScreen = observer(() => { {deckListStore.myInfo && ( <>
- +
@@ -159,7 +157,7 @@ export const MainScreen = observer(() => { screenStore.go({ type: "userSettings" }); }} > - Settings + {t("settings")}
diff --git a/src/screens/deck-list/view-more-decks-toggle.tsx b/src/screens/deck-list/view-more-decks-toggle.tsx index 6606d80c..52814a47 100644 --- a/src/screens/deck-list/view-more-decks-toggle.tsx +++ b/src/screens/deck-list/view-more-decks-toggle.tsx @@ -5,6 +5,7 @@ import { theme } from "../../ui/theme.tsx"; import { deckListStore } from "../../store/deck-list-store.ts"; import { ChevronIcon } from "../../ui/chevron-icon.tsx"; import React from "react"; +import { t } from "../../translations/t.ts"; export const ViewMoreDecksToggle = observer(() => { return ( @@ -30,7 +31,9 @@ export const ViewMoreDecksToggle = observer(() => { direction={deckListStore.isMyDecksExpanded.value ? "top" : "bottom"} /> - {deckListStore.isMyDecksExpanded.value ? "Hide" : "Show all"} + {deckListStore.isMyDecksExpanded.value + ? t("hide_all_decks") + : t("show_all_decks")} ); }); diff --git a/src/screens/deck-review/card-speaker.tsx b/src/screens/deck-review/card-speaker.tsx index 47631520..e004a77d 100644 --- a/src/screens/deck-review/card-speaker.tsx +++ b/src/screens/deck-review/card-speaker.tsx @@ -22,7 +22,7 @@ export const CardSpeaker = observer((props: Props) => { return null; } - // throttle is needed to avoid user clicking on the speaker button many times in a row hence creating many sounds + // throttle is needed to avoid user clicking on the speaker many times in a row hence creating many sounds return ( { })} >

- {type === "deck" - ? `You have finished this deck for now 🎉` - : `You have repeated all the cards for today 🎉`} + {type === "deck" ? t("review_deck_finished") : t("review_all_cards")}

- {type === "repeat_all" && newCardsCount && newCardsCount > 1 ? ( + {type === "repeat_all" && newCardsCount ? (

- Want more? You have{" "} + {t("review_finished_want_more")}{" "} { screenStore.go({ type: "main" }); }} > - {newCardsCount} + {newCardsCount} {translateNewCardsCount(newCardsCount)} {" "} - new cards to study + {t("review_finished_to_review")}

) : ( -

{random(encouragingMessages)} 😊

+

{getEncouragingMessage()} 😊

)}
diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index 2d997d6b..3b19b428 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -13,6 +13,7 @@ import { showConfirm } from "../../lib/telegram/show-confirm.ts"; import { ButtonSideAligned } from "../../ui/button-side-aligned.tsx"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; import { apiDuplicateDeckRequest } from "../../api/api.ts"; +import { t } from "../../translations/t.ts"; export const DeckPreview = observer(() => { const reviewStore = useReviewStore(); @@ -24,7 +25,7 @@ export const DeckPreview = observer(() => { useTelegramProgress(() => deckListStore.isDeckCardsLoading); useMainButton( - "Review deck", + t("review_deck"), () => { deckListStore.startDeckReview(reviewStore); }, @@ -66,7 +67,6 @@ export const DeckPreview = observer(() => {

{deck.name}

-

Description

{deck.description}
{!deckListStore.isDeckCardsLoading && ( @@ -80,7 +80,7 @@ export const DeckPreview = observer(() => { })} >
- Cards to repeat: + {t("cards_to_repeat")}:

{ deck.cardsToReview.filter((card) => card.type === "repeat") @@ -89,7 +89,7 @@ export const DeckPreview = observer(() => {

- New cards: + {t("cards_new")}:

{ deck.cardsToReview.filter((card) => card.type === "new") @@ -98,7 +98,7 @@ export const DeckPreview = observer(() => {

- Total cards: + {t("cards_total")}:

{deck.deck_card.length}

@@ -122,7 +122,7 @@ export const DeckPreview = observer(() => { }); }} > - Add card + {t("add_card_short")} ) : null} {deckListStore.user?.is_admin && ( @@ -130,14 +130,14 @@ export const DeckPreview = observer(() => { icon={"mdi-content-duplicate mdi-24px"} outline onClick={() => { - showConfirm("Are you sure to duplicate this deck?").then(() => { + showConfirm(t("duplicate_confirm")).then(() => { apiDuplicateDeckRequest(deck.id).then(() => { screenStore.go({ type: "main" }); }); }); }} > - Duplicate + {t("duplicate")} )} {deckListStore.canEditDeck(deck) ? ( @@ -148,7 +148,7 @@ export const DeckPreview = observer(() => { screenStore.go({ type: "deckForm", deckId: deck.id }); }} > - Edit + {t("edit")} ) : null} {screenStore.screen.type === "deckMine" ? ( @@ -156,14 +156,12 @@ export const DeckPreview = observer(() => { icon={"mdi-delete-circle mdi-24px"} outline onClick={() => { - showConfirm( - "Are you sure to remove the deck from your collection? This action can't be undone", - ).then(() => { + showConfirm(t("delete_deck_confirm")).then(() => { deckListStore.removeDeck(); }); }} > - Delete + {t("delete")} ) : null} @@ -171,10 +169,7 @@ export const DeckPreview = observer(() => { {deck.cardsToReview.length === 0 && ( - - Amazing work! 🌟 You've reviewed all the cards in this deck for now. - Come back later for more. - + {t("no_cards_to_review_in_deck")} )} ); diff --git a/src/screens/deck-review/repeat-all-screen.tsx b/src/screens/deck-review/repeat-all-screen.tsx index 32623790..fdc98b25 100644 --- a/src/screens/deck-review/repeat-all-screen.tsx +++ b/src/screens/deck-review/repeat-all-screen.tsx @@ -6,6 +6,7 @@ import { DeckFinished } from "./deck-finished.tsx"; import { Review } from "./review.tsx"; import React from "react"; import { Hint } from "../../ui/hint.tsx"; +import { t } from "../../translations/t.ts"; export const RepeatAllScreen = observer(() => { const reviewStore = useReviewStore(); @@ -30,10 +31,7 @@ export const RepeatAllScreen = observer(() => { return (
- - Amazing work! 🌟 You've repeated all the cards for today. Come back - later for more. - + {t("no_cards_to_review_all")}
); }); diff --git a/src/screens/deck-review/review.tsx b/src/screens/deck-review/review.tsx index ae7e3e07..59edf9a0 100644 --- a/src/screens/deck-review/review.tsx +++ b/src/screens/deck-review/review.tsx @@ -11,6 +11,7 @@ import { useReviewStore } from "../../store/review-store-context.tsx"; import { Button } from "../../ui/button.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { useHotkeys } from "react-hotkeys-hook"; +import { t } from "../../translations/t.ts"; const rotateBorder = 80; @@ -176,10 +177,10 @@ export const Review = observer(() => { {reviewStore.currentCard.isOpened && !isRotateAnimating ? ( <> ) : ( @@ -189,7 +190,7 @@ export const Review = observer(() => { reviewStore.open(); }} > - Show answer + {t("review_show_answer")} )} diff --git a/src/screens/deck-review/share-deck-button.tsx b/src/screens/deck-review/share-deck-button.tsx index 92468d4e..5cadaac1 100644 --- a/src/screens/deck-review/share-deck-button.tsx +++ b/src/screens/deck-review/share-deck-button.tsx @@ -3,6 +3,7 @@ import { assert } from "../../lib/typescript/assert.ts"; import { trimEnd } from "../../lib/string/trim.ts"; import WebApp from "@twa-dev/sdk"; import { ButtonSideAligned } from "../../ui/button-side-aligned.tsx"; +import { t } from "../../translations/t.ts"; type Props = { shareId?: string | null; @@ -25,7 +26,7 @@ export const ShareDeckButton = (props: Props) => { outline onClick={onClick} > - Share + {t("share")} ); }; diff --git a/src/screens/shared/version-warning.tsx b/src/screens/shared/version-warning.tsx index 3501fb3c..8622b5e5 100644 --- a/src/screens/shared/version-warning.tsx +++ b/src/screens/shared/version-warning.tsx @@ -1,6 +1,7 @@ import WebApp from "@twa-dev/sdk"; import { css } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; +import { t } from "../../translations/t.ts"; export const VersionWarning = () => { if (WebApp.isVersionAtLeast("6.1")) { @@ -22,13 +23,13 @@ export const VersionWarning = () => { marginBottom: 8, })} > -
Your Telegram is outdated
+
{t("warning_telegram_outdated_title")}
- Please update your Telegram to ensure stable functioning of this app. + {t("warning_telegram_outdated_description")}
); diff --git a/src/screens/user-settings/user-settings-main.tsx b/src/screens/user-settings/user-settings-main.tsx index 1a69f5f0..78746ea1 100644 --- a/src/screens/user-settings/user-settings-main.tsx +++ b/src/screens/user-settings/user-settings-main.tsx @@ -15,6 +15,7 @@ import { css } from "@emotion/css"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; +import { t } from "../../translations/t.ts"; export const timeRanges = generateTimeRange(); @@ -25,7 +26,7 @@ export const UserSettingsMain = observer(() => { }); useMainButton( - "Save", + t("save"), () => userSettingsStore.submit(), () => userSettingsStore.isSaveVisible, ); @@ -44,7 +45,7 @@ export const UserSettingsMain = observer(() => { return (
- +
{ })} > - Review notifications + {t("settings_review_notifications")} { {isRemindNotifyEnabled.value && ( - Time + {t("settings_time")}