feat(mobx): implement locale options
parent
3fc49a3835
commit
7c665b3b40
|
|
@ -1,23 +1,21 @@
|
||||||
import { dispatch } from "../../redux";
|
import { useApplicationState } from "../../mobx/State";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
|
|
||||||
import { Language, Languages } from "../../context/Locale";
|
import { Language, Languages } from "../../context/Locale";
|
||||||
|
|
||||||
import ComboBox from "../ui/ComboBox";
|
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 (
|
return (
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value={props.locale}
|
value={locale.getLanguage()}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
dispatch({
|
locale.setLanguage(e.currentTarget.value as Language)
|
||||||
type: "SET_LOCALE",
|
|
||||||
locale: e.currentTarget.value as Language,
|
|
||||||
})
|
|
||||||
}>
|
}>
|
||||||
{Object.keys(Languages).map((x) => {
|
{Object.keys(Languages).map((x) => {
|
||||||
const l = Languages[x as keyof typeof Languages];
|
const l = Languages[x as keyof typeof Languages];
|
||||||
|
|
@ -30,9 +28,3 @@ export function LocaleSelector(props: Props) {
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(LocaleSelector, (state) => {
|
|
||||||
return {
|
|
||||||
locale: state.locale,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ import calendar from "dayjs/plugin/calendar";
|
||||||
import format from "dayjs/plugin/localizedFormat";
|
import format from "dayjs/plugin/localizedFormat";
|
||||||
import update from "dayjs/plugin/updateLocale";
|
import update from "dayjs/plugin/updateLocale";
|
||||||
import defaultsDeep from "lodash.defaultsdeep";
|
import defaultsDeep from "lodash.defaultsdeep";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import { IntlProvider } from "preact-i18n";
|
import { IntlProvider } from "preact-i18n";
|
||||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { connectState } from "../redux/connector";
|
import { useApplicationState } from "../mobx/State";
|
||||||
|
|
||||||
import definition from "../../external/lang/en.json";
|
import definition from "../../external/lang/en.json";
|
||||||
|
|
||||||
|
|
@ -222,59 +223,14 @@ export interface Dictionary {
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Locale({ children, locale }: Props) {
|
export default observer(({ children }: Props) => {
|
||||||
const [defns, setDefinition] = useState<Dictionary>(
|
const locale = useApplicationState().locale;
|
||||||
|
const [definitions, setDefinition] = useState<Dictionary>(
|
||||||
definition as Dictionary,
|
definition as Dictionary,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load relevant language information, fallback to English if invalid.
|
const lang = locale.getLanguage();
|
||||||
const lang = Languages[locale] ?? Languages.en;
|
const source = Languages[lang];
|
||||||
|
|
||||||
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 loadLanguage = useCallback(
|
const loadLanguage = useCallback(
|
||||||
(locale: string) => {
|
(locale: string) => {
|
||||||
|
|
@ -288,13 +244,13 @@ function Locale({ children, locale }: Props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
import(`../../external/lang/${lang.i18n}.json`).then(
|
import(`../../external/lang/${source.i18n}.json`).then(
|
||||||
async (lang_file) => {
|
async (lang_file) => {
|
||||||
// Transform the definitions data.
|
// Transform the definitions data.
|
||||||
const defn = transformLanguage(lang_file.default);
|
const defn = transformLanguage(lang_file.default);
|
||||||
|
|
||||||
// Determine and load dayjs locales.
|
// Determine and load dayjs locales.
|
||||||
const target = lang.dayjs ?? lang.i18n;
|
const target = source.dayjs ?? source.i18n;
|
||||||
const dayjs_locale = await import(
|
const dayjs_locale = await import(
|
||||||
`../../node_modules/dayjs/esm/locale/${target}.js`
|
`../../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(() => {
|
useEffect(() => {
|
||||||
// Apply RTL language format.
|
// Apply RTL language format.
|
||||||
document.body.style.direction = lang.rtl ? "rtl" : "";
|
document.body.style.direction = source.rtl ? "rtl" : "";
|
||||||
}, [lang.rtl]);
|
}, [source.rtl]);
|
||||||
|
|
||||||
return <IntlProvider definition={defns}>{children}</IntlProvider>;
|
return <IntlProvider definition={definitions}>{children}</IntlProvider>;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Omit<Props, "locale">>(
|
|
||||||
Locale,
|
|
||||||
(state) => {
|
|
||||||
return {
|
|
||||||
locale: state.locale,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import Auth from "./stores/Auth";
|
import Auth from "./stores/Auth";
|
||||||
import Draft from "./stores/Draft";
|
import Draft from "./stores/Draft";
|
||||||
|
import LocaleOptions from "./stores/LocaleOptions";
|
||||||
|
|
||||||
interface StoreDefinition {
|
interface StoreDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -20,6 +21,7 @@ interface StoreDefinition {
|
||||||
export default class State {
|
export default class State {
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
draft: Draft;
|
draft: Draft;
|
||||||
|
locale: LocaleOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct new State.
|
* Construct new State.
|
||||||
|
|
@ -27,6 +29,7 @@ export default class State {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.auth = new Auth();
|
this.auth = new Auth();
|
||||||
this.draft = new Draft();
|
this.draft = new Draft();
|
||||||
|
this.locale = new LocaleOptions();
|
||||||
|
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,21 @@ export default class LocaleOptions implements Persistent<Data> {
|
||||||
|
|
||||||
// eslint-disable-next-line require-jsdoc
|
// eslint-disable-next-line require-jsdoc
|
||||||
@action hydrate(data: Data) {
|
@action hydrate(data: Data) {
|
||||||
this.lang = data.lang;
|
this.setLanguage(data.lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current language.
|
* Get current language.
|
||||||
*/
|
*/
|
||||||
@computed getLang() {
|
@computed getLanguage() {
|
||||||
return this.lang;
|
return this.lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current language.
|
||||||
|
*/
|
||||||
|
@action setLanguage(language: Language) {
|
||||||
|
if (typeof Languages[language] === "undefined") return;
|
||||||
|
this.lang = language;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
|
||||||
import styles from "./Panes.module.scss";
|
import styles from "./Panes.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
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 { dispatch } from "../../../redux";
|
||||||
import { connectState } from "../../../redux/connector";
|
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 tamilFlagPNG from "../assets/tamil_nadu_flag.png";
|
||||||
import tokiponaSVG from "../assets/toki_pona.svg";
|
import tokiponaSVG from "../assets/toki_pona.svg";
|
||||||
|
|
||||||
type Props = {
|
type Key = [Language, LanguageEntry];
|
||||||
locale: Language;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
key={x}
|
key={x}
|
||||||
className={styles.entry}
|
className={styles.entry}
|
||||||
checked={locale === x}
|
checked={selected}
|
||||||
onChange={(v) => {
|
onChange={onSelect}>
|
||||||
if (v) {
|
|
||||||
dispatch({
|
|
||||||
type: "SET_LOCALE",
|
|
||||||
locale: x as Language,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<div className={styles.flag}>
|
<div className={styles.flag}>
|
||||||
{lang.i18n === "ta" ? (
|
{lang.i18n === "ta" ? (
|
||||||
<img
|
<img
|
||||||
|
|
@ -61,36 +67,58 @@ function Entry({ entry: [x, lang], locale }: { entry: Key } & Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Component(props: Props) {
|
/**
|
||||||
const languages = Object.keys(Langs).map((x) => [
|
* Component providing the language selection menu.
|
||||||
x,
|
*/
|
||||||
Langs[x as keyof typeof Langs],
|
export const Languages = observer(() => {
|
||||||
]) as Key[];
|
const locale = useApplicationState().locale;
|
||||||
|
const language = locale.getLanguage();
|
||||||
|
|
||||||
// Get the user's system language. Check for exact
|
// Generate languages array.
|
||||||
// matches first, otherwise check for partial matches
|
const languages = useMemo(() => {
|
||||||
const preferredLanguage =
|
const languages = Object.keys(Langs).map((x) => [
|
||||||
navigator.languages.filter((lang) =>
|
x,
|
||||||
languages.find((l) => l[0].replace(/_/g, "-") == lang),
|
Langs[x as keyof typeof Langs],
|
||||||
)?.[0] ||
|
]) as Key[];
|
||||||
navigator.languages
|
|
||||||
?.map((x) => x.split("-")[0])
|
|
||||||
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
|
|
||||||
?.split("-")[0];
|
|
||||||
|
|
||||||
if (preferredLanguage) {
|
// Get the user's system language. Check for exact
|
||||||
// This moves the user's system language to the top of the language list
|
// matches first, otherwise check for partial matches
|
||||||
const prefLangKey = languages.find(
|
const preferredLanguage =
|
||||||
(lang) => lang[0].replace(/_/g, "-") == preferredLanguage,
|
navigator.languages.filter((lang) =>
|
||||||
);
|
languages.find((l) => l[0].replace(/_/g, "-") == lang),
|
||||||
if (prefLangKey) {
|
)?.[0] ||
|
||||||
languages.splice(
|
navigator.languages
|
||||||
0,
|
?.map((x) => x.split("-")[0])
|
||||||
0,
|
?.filter((lang) => languages.find((l) => l[0] == lang))?.[0]
|
||||||
languages.splice(languages.indexOf(prefLangKey), 1)[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) => (
|
||||||
|
<Entry
|
||||||
|
key={x}
|
||||||
|
entry={[x, lang]}
|
||||||
|
selected={language === x}
|
||||||
|
onSelect={() => locale.setLanguage(x)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.languages}>
|
<div className={styles.languages}>
|
||||||
|
|
@ -98,11 +126,7 @@ export function Component(props: Props) {
|
||||||
<Text id="app.settings.pages.language.select" />
|
<Text id="app.settings.pages.language.select" />
|
||||||
</h3>
|
</h3>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages.filter(([, lang]) => !lang.cat).map(EntryFactory)}
|
||||||
.filter(([, lang]) => !lang.cat)
|
|
||||||
.map(([x, lang]) => (
|
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.language.const" />
|
<Text id="app.settings.pages.language.const" />
|
||||||
|
|
@ -110,9 +134,7 @@ export function Component(props: Props) {
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages
|
||||||
.filter(([, lang]) => lang.cat === "const")
|
.filter(([, lang]) => lang.cat === "const")
|
||||||
.map(([x, lang]) => (
|
.map(EntryFactory)}
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<h3>
|
<h3>
|
||||||
<Text id="app.settings.pages.language.other" />
|
<Text id="app.settings.pages.language.other" />
|
||||||
|
|
@ -120,9 +142,7 @@ export function Component(props: Props) {
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{languages
|
{languages
|
||||||
.filter(([, lang]) => lang.cat === "alt")
|
.filter(([, lang]) => lang.cat === "alt")
|
||||||
.map(([x, lang]) => (
|
.map(EntryFactory)}
|
||||||
<Entry key={x} entry={[x, lang]} {...props} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<Tip>
|
<Tip>
|
||||||
<span>
|
<span>
|
||||||
|
|
@ -137,10 +157,4 @@ export function Component(props: Props) {
|
||||||
</Tip>
|
</Tip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export const Languages = connectState(Component, (state) => {
|
|
||||||
return {
|
|
||||||
locale: state.locale,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue