diff --git a/src/components/common/LocaleSelector.tsx b/src/components/common/LocaleSelector.tsx index 8099f510..5fdaef24 100644 --- a/src/components/common/LocaleSelector.tsx +++ b/src/components/common/LocaleSelector.tsx @@ -1,23 +1,21 @@ -import { dispatch } from "../../redux"; -import { connectState } from "../../redux/connector"; +import { useApplicationState } from "../../mobx/State"; import { Language, Languages } from "../../context/Locale"; import ComboBox from "../ui/ComboBox"; -type Props = { - locale: string; -}; +/** + * Component providing a language selector combobox. + * Note: this is not an observer but this is fine as we are just using a combobox. + */ +export default function LocaleSelector() { + const locale = useApplicationState().locale; -export function LocaleSelector(props: Props) { return ( - dispatch({ - type: "SET_LOCALE", - locale: e.currentTarget.value as Language, - }) + locale.setLanguage(e.currentTarget.value as Language) }> {Object.keys(Languages).map((x) => { const l = Languages[x as keyof typeof Languages]; @@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) { ); } - -export default connectState(LocaleSelector, (state) => { - return { - locale: state.locale, - }; -}); diff --git a/src/context/Locale.tsx b/src/context/Locale.tsx index fe5504f3..7a3e9fac 100644 --- a/src/context/Locale.tsx +++ b/src/context/Locale.tsx @@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar"; import format from "dayjs/plugin/localizedFormat"; import update from "dayjs/plugin/updateLocale"; import defaultsDeep from "lodash.defaultsdeep"; +import { observer } from "mobx-react-lite"; import { IntlProvider } from "preact-i18n"; import { useCallback, useEffect, useState } from "preact/hooks"; -import { connectState } from "../redux/connector"; +import { useApplicationState } from "../mobx/State"; import definition from "../../external/lang/en.json"; @@ -222,59 +223,14 @@ export interface Dictionary { | undefined; } -function Locale({ children, locale }: Props) { - const [defns, setDefinition] = useState( +export default observer(({ children }: Props) => { + const locale = useApplicationState().locale; + const [definitions, setDefinition] = useState( definition as Dictionary, ); - // Load relevant language information, fallback to English if invalid. - const lang = Languages[locale] ?? Languages.en; - - function transformLanguage(source: Dictionary) { - // Fallback untranslated strings to English (UK) - const obj = defaultsDeep(source, definition); - - // Take relevant objects out, dayjs and defaults - // should exist given we just took defaults above. - const { dayjs } = obj; - const { defaults } = dayjs; - - // Determine whether we are using 12-hour clock. - const twelvehour = defaults?.twelvehour - ? defaults.twelvehour === "yes" - : false; - - // Determine what date separator we are using. - const separator: string = defaults?.date_separator ?? "/"; - - // Determine what date format we are using. - const date: "traditional" | "simplified" | "ISO8601" = - defaults?.date_format ?? "traditional"; - - // Available date formats. - const DATE_FORMATS = { - traditional: `DD${separator}MM${separator}YYYY`, - simplified: `MM${separator}DD${separator}YYYY`, - ISO8601: "YYYY-MM-DD", - }; - - // Replace data in dayjs object, make sure to provide fallbacks. - dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional; - dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm"; - - // Replace {{time}} format string in dayjs strings with the time format. - Object.keys(dayjs) - .filter((k) => typeof dayjs[k] === "string") - .forEach( - (k) => - (dayjs[k] = dayjs[k].replace( - /{{time}}/g, - dayjs["timeFormat"], - )), - ); - - return obj; - } + const lang = locale.getLanguage(); + const source = Languages[lang]; const loadLanguage = useCallback( (locale: string) => { @@ -288,13 +244,13 @@ function Locale({ children, locale }: Props) { return; } - import(`../../external/lang/${lang.i18n}.json`).then( + import(`../../external/lang/${source.i18n}.json`).then( async (lang_file) => { // Transform the definitions data. const defn = transformLanguage(lang_file.default); // Determine and load dayjs locales. - const target = lang.dayjs ?? lang.i18n; + const target = source.dayjs ?? source.i18n; const dayjs_locale = await import( `../../node_modules/dayjs/esm/locale/${target}.js` ); @@ -312,25 +268,63 @@ function Locale({ children, locale }: Props) { }, ); }, - [lang.dayjs, lang.i18n], + [source.dayjs, source.i18n], ); - useEffect(() => loadLanguage(locale), [locale, lang, loadLanguage]); + useEffect(() => loadLanguage(lang), [lang, source, loadLanguage]); useEffect(() => { // Apply RTL language format. - document.body.style.direction = lang.rtl ? "rtl" : ""; - }, [lang.rtl]); + document.body.style.direction = source.rtl ? "rtl" : ""; + }, [source.rtl]); - return {children}; + return {children}; +}); + +/** + * Apply defaults and process dayjs entries for a langauge. + * @param source Dictionary definition to transform + * @returns Transformed dictionary definition + */ +function transformLanguage(source: Dictionary) { + // Fallback untranslated strings to English (UK) + const obj = defaultsDeep(source, definition); + + // Take relevant objects out, dayjs and defaults + // should exist given we just took defaults above. + const { dayjs } = obj; + const { defaults } = dayjs; + + // Determine whether we are using 12-hour clock. + const twelvehour = defaults?.twelvehour + ? defaults.twelvehour === "yes" + : false; + + // Determine what date separator we are using. + const separator: string = defaults?.date_separator ?? "/"; + + // Determine what date format we are using. + const date: "traditional" | "simplified" | "ISO8601" = + defaults?.date_format ?? "traditional"; + + // Available date formats. + const DATE_FORMATS = { + traditional: `DD${separator}MM${separator}YYYY`, + simplified: `MM${separator}DD${separator}YYYY`, + ISO8601: "YYYY-MM-DD", + }; + + // Replace data in dayjs object, make sure to provide fallbacks. + dayjs["sameElse"] = DATE_FORMATS[date] ?? DATE_FORMATS.traditional; + dayjs["timeFormat"] = twelvehour ? "hh:mm A" : "HH:mm"; + + // Replace {{time}} format string in dayjs strings with the time format. + Object.keys(dayjs) + .filter((k) => typeof dayjs[k] === "string") + .forEach( + (k) => + (dayjs[k] = dayjs[k].replace(/{{time}}/g, dayjs["timeFormat"])), + ); + + return obj; } - -export default connectState>( - Locale, - (state) => { - return { - locale: state.locale, - }; - }, - true, -); diff --git a/src/mobx/State.ts b/src/mobx/State.ts index 5023062a..752f2cdf 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -5,6 +5,7 @@ import { useContext } from "preact/hooks"; import Auth from "./stores/Auth"; import Draft from "./stores/Draft"; +import LocaleOptions from "./stores/LocaleOptions"; interface StoreDefinition { id: string; @@ -20,6 +21,7 @@ interface StoreDefinition { export default class State { auth: Auth; draft: Draft; + locale: LocaleOptions; /** * Construct new State. @@ -27,6 +29,7 @@ export default class State { constructor() { this.auth = new Auth(); this.draft = new Draft(); + this.locale = new LocaleOptions(); makeAutoObservable(this); } diff --git a/src/mobx/stores/LocaleOptions.ts b/src/mobx/stores/LocaleOptions.ts index 6f88d2f7..aed6e70a 100644 --- a/src/mobx/stores/LocaleOptions.ts +++ b/src/mobx/stores/LocaleOptions.ts @@ -72,13 +72,21 @@ export default class LocaleOptions implements Persistent { // eslint-disable-next-line require-jsdoc @action hydrate(data: Data) { - this.lang = data.lang; + this.setLanguage(data.lang); } /** * Get current language. */ - @computed getLang() { + @computed getLanguage() { return this.lang; } + + /** + * Set current language. + */ + @action setLanguage(language: Language) { + if (typeof Languages[language] === "undefined") return; + this.lang = language; + } } diff --git a/src/pages/settings/panes/Languages.tsx b/src/pages/settings/panes/Languages.tsx index 873b4611..98f76e0d 100644 --- a/src/pages/settings/panes/Languages.tsx +++ b/src/pages/settings/panes/Languages.tsx @@ -1,6 +1,13 @@ +import { observer } from "mobx-react-lite"; + import styles from "./Panes.module.scss"; import { Text } from "preact-i18n"; +import { useMemo } from "preact/hooks"; +import PaintCounter from "../../../lib/PaintCounter"; + +import { useApplicationState } from "../../../mobx/State"; +import LocaleOptions from "../../../mobx/stores/LocaleOptions"; import { dispatch } from "../../../redux"; import { connectState } from "../../../redux/connector"; @@ -17,26 +24,25 @@ import enchantingTableWEBP from "../assets/enchanting_table.webp"; import tamilFlagPNG from "../assets/tamil_nadu_flag.png"; import tokiponaSVG from "../assets/toki_pona.svg"; -type Props = { - locale: Language; -}; +type Key = [Language, LanguageEntry]; -type Key = [string, LanguageEntry]; +interface Props { + entry: Key; + selected: boolean; + onSelect: () => void; +} -function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) { +/** + * Component providing individual language entries. + * @param param0 Entry data + */ +function Entry({ entry: [x, lang], selected, onSelect }: Props) { return ( { - if (v) { - dispatch({ - type: "SET_LOCALE", - locale: x as Language, - }); - } - }}> + checked={selected} + onChange={onSelect}>
{lang.i18n === "ta" ? ( [ - x, - Langs[x as keyof typeof Langs], - ]) as Key[]; +/** + * Component providing the language selection menu. + */ +export const Languages = observer(() => { + const locale = useApplicationState().locale; + const language = locale.getLanguage(); - // Get the user's system language. Check for exact - // matches first, otherwise check for partial matches - const preferredLanguage = - navigator.languages.filter((lang) => - languages.find((l) => l[0].replace(/_/g, "-") == lang), - )?.[0] || - navigator.languages - ?.map((x) => x.split("-")[0]) - ?.filter((lang) => languages.find((l) => l[0] == lang))?.[0] - ?.split("-")[0]; + // Generate languages array. + const languages = useMemo(() => { + const languages = Object.keys(Langs).map((x) => [ + x, + Langs[x as keyof typeof Langs], + ]) as Key[]; - if (preferredLanguage) { - // This moves the user's system language to the top of the language list - const prefLangKey = languages.find( - (lang) => lang[0].replace(/_/g, "-") == preferredLanguage, - ); - if (prefLangKey) { - languages.splice( - 0, - 0, - languages.splice(languages.indexOf(prefLangKey), 1)[0], + // Get the user's system language. Check for exact + // matches first, otherwise check for partial matches + const preferredLanguage = + navigator.languages.filter((lang) => + languages.find((l) => l[0].replace(/_/g, "-") == lang), + )?.[0] || + navigator.languages + ?.map((x) => x.split("-")[0]) + ?.filter((lang) => languages.find((l) => l[0] == lang))?.[0] + ?.split("-")[0]; + + if (preferredLanguage) { + // This moves the user's system language to the top of the language list + const prefLangKey = languages.find( + (lang) => lang[0].replace(/_/g, "-") == preferredLanguage, ); + + if (prefLangKey) { + languages.splice( + 0, + 0, + languages.splice(languages.indexOf(prefLangKey), 1)[0], + ); + } } - } + + return languages; + }, []); + + // Creates entries with given key. + const EntryFactory = ([x, lang]: Key) => ( + locale.setLanguage(x)} + /> + ); return (
@@ -98,11 +126,7 @@ export function Component(props: Props) {
- {languages - .filter(([, lang]) => !lang.cat) - .map(([x, lang]) => ( - - ))} + {languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}

@@ -110,9 +134,7 @@ export function Component(props: Props) {
{languages .filter(([, lang]) => lang.cat === "const") - .map(([x, lang]) => ( - - ))} + .map(EntryFactory)}

@@ -120,9 +142,7 @@ export function Component(props: Props) {
{languages .filter(([, lang]) => lang.cat === "alt") - .map(([x, lang]) => ( - - ))} + .map(EntryFactory)}
@@ -137,10 +157,4 @@ export function Component(props: Props) {

); -} - -export const Languages = connectState(Component, (state) => { - return { - locale: state.locale, - }; });