diff --git a/.vscode/settings.json b/.vscode/settings.json index 6abc765..fb0d607 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,5 +47,16 @@ "scss", "pcss", "postcss" - ] + ], + + // Enable i18n-ally + "i18n-ally.localesPaths": "desktop/renderer/src/locales", + "i18n-ally.pathMatcher": "{locale}.json", + "i18n-ally.displayLanguage": "zh-CN", + "i18n-ally.namespace": false, + "i18n-ally.keystyle": "nested", + "i18n-ally.sortKeys": true, + "i18n-ally.keepFulfilled": true, + "i18n-ally.enabledFrameworks": ["react"], + "i18n-ally.enabledParsers": ["json"], } diff --git a/desktop/renderer/package.json b/desktop/renderer/package.json index c66ff89..fd77d77 100644 --- a/desktop/renderer/package.json +++ b/desktop/renderer/package.json @@ -23,7 +23,9 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.27.0", "value-enhancer": "overridden", - "use-value-enhancer": "overridden" + "use-value-enhancer": "overridden", + "val-i18n": "^0.1.11", + "val-i18n-react": "^0.1.5" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/desktop/renderer/src/App.tsx b/desktop/renderer/src/App.tsx index 2b657dc..83aa300 100644 --- a/desktop/renderer/src/App.tsx +++ b/desktop/renderer/src/App.tsx @@ -1,8 +1,10 @@ import type { PropsWithChildren } from "react"; import React from "react"; import { useVal } from "use-value-enhancer"; +import { I18nProvider } from "val-i18n-react"; import { AppContextProvider } from "./components/AppContextProvider"; import { ThemeProvider } from "./components/ThemeProvider"; +import { useI18nLoader } from "./hooks"; import { type AppContext, Routes } from "./routes"; export interface StudioHomeProps { @@ -14,15 +16,22 @@ export const StudioHome = ({ children, }: PropsWithChildren) => { const prefersColorScheme = useVal(appContext.settingStore.prefersColorScheme$); + const i18n = useI18nLoader(appContext.settingStore.localLanguage$); + + if (!i18n) { + return null; // blank page + } return ( - - - {children} - + + + + {children} + + ); }; diff --git a/desktop/renderer/src/components/AppearancePicker/index.tsx b/desktop/renderer/src/components/AppearancePicker/index.tsx index 7d3b6e4..09cb842 100644 --- a/desktop/renderer/src/components/AppearancePicker/index.tsx +++ b/desktop/renderer/src/components/AppearancePicker/index.tsx @@ -4,8 +4,9 @@ import type { OOMOLPrefersColorScheme } from "../ThemeProvider"; import { Radio } from "antd"; import React from "react"; -import styles from "./AppearancePicker.module.scss"; +import { useTranslate } from "val-i18n-react"; +import styles from "./AppearancePicker.module.scss"; import autoSVG from "./icons/auto.svg"; import darkSVG from "./icons/dark.svg"; import lightSVG from "./icons/light.svg"; @@ -19,29 +20,26 @@ export const AppearancePicker: React.FC = ({ defaultValue, changeAppearance, }) => { - // const t = useTranslate(); + const t = useTranslate(); return (
- {/* {t("settings.theme-light")} */} - light + {t("settings.theme-light")}
- {/* {t("settings.theme-dark")} */} - dark + {t("settings.theme-dark")}
- {/* {t("settings.theme-auto")} */} - auto + {t("settings.theme-auto")}
diff --git a/desktop/renderer/src/hooks/i18n-loader.ts b/desktop/renderer/src/hooks/i18n-loader.ts new file mode 100644 index 0000000..dfb4622 --- /dev/null +++ b/desktop/renderer/src/hooks/i18n-loader.ts @@ -0,0 +1,55 @@ +import type { Locale, LocaleLang } from "val-i18n"; +import type { ReadonlyVal } from "value-enhancer"; + +import { useEffect } from "react"; +import { detectLang, I18n } from "val-i18n"; +import { joinDisposers } from "~/misc/utils"; +import { useAsyncMemo } from "./use-async-memo"; + +const i18nLoader = (lang?: string): Promise => { + const localeModules = import.meta.glob( + "~/locales/*.json", + { + import: "default", + }, + ); + + const localeLoaders = Object.keys(localeModules).reduce( + (loaders, path) => { + if (localeModules[path]) { + const langMatch = path.match(/\/([^/]+)\.json$/); + if (langMatch) { + loaders[langMatch[1]] = localeModules[path]; + } + } + return loaders; + }, + {} as Record Promise>, + ); + + const langs = Object.keys(localeLoaders); + + return I18n.preload( + lang && localeLoaders[lang] + ? lang + : detectLang(langs) || (localeLoaders.en ? "en" : langs[0]), + lang => localeLoaders[lang](), + ); +}; + +export const useI18nLoader = ( + localeLang$: ReadonlyVal, +): I18n | undefined => { + const maybeI18n = useAsyncMemo(() => i18nLoader(localeLang$.value), []); + useEffect( + () => + joinDisposers( + localeLang$.subscribe(lang => lang && void maybeI18n?.switchLang(lang)), + maybeI18n?.lang$.subscribe((lang) => { + document.documentElement.lang = lang; + }), + ), + [maybeI18n, localeLang$], + ); + return maybeI18n; +}; diff --git a/desktop/renderer/src/hooks/index.ts b/desktop/renderer/src/hooks/index.ts index c012bf2..db7fc3e 100644 --- a/desktop/renderer/src/hooks/index.ts +++ b/desktop/renderer/src/hooks/index.ts @@ -1,2 +1,4 @@ +export * from "./i18n-loader"; export * from "./use-app-context"; +export * from "./use-async-memo"; export * from "./use-isomorphic-layout-effect"; diff --git a/desktop/renderer/src/hooks/use-async-memo.ts b/desktop/renderer/src/hooks/use-async-memo.ts new file mode 100644 index 0000000..3d5a8d7 --- /dev/null +++ b/desktop/renderer/src/hooks/use-async-memo.ts @@ -0,0 +1,36 @@ +import type { DependencyList } from "react"; + +import { useState } from "react"; +import { isPromise } from "~/misc/utils"; + +import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect"; + +export const useAsyncMemo = ( + effect: (oldValue: T | undefined) => Promise | T, + deps: DependencyList = [], +): T | undefined => { + const [value, setValue] = useState(); + + useIsomorphicLayoutEffect(() => { + let isValid = true; + + const maybePromise = effect(value); + + if (isPromise(maybePromise)) { + void maybePromise.then((value) => { + if (isValid) { + setValue(value); + } + }); + } + else { + setValue(maybePromise); + } + + return () => { + isValid = false; + }; + }, deps); + + return value; +}; diff --git a/desktop/renderer/src/locales/en.json b/desktop/renderer/src/locales/en.json new file mode 100644 index 0000000..ae3fc51 --- /dev/null +++ b/desktop/renderer/src/locales/en.json @@ -0,0 +1,10 @@ +{ + "settings": { + "appearance": "Appearance", + "chinese": "Chinese", + "language": "Language", + "theme-light": "Light", + "theme-dark": "Dark", + "theme-auto": "Auto" + } +} diff --git a/desktop/renderer/src/locales/index.ts b/desktop/renderer/src/locales/index.ts new file mode 100644 index 0000000..8463f93 --- /dev/null +++ b/desktop/renderer/src/locales/index.ts @@ -0,0 +1,2 @@ +export { default as en } from "./en.json"; +export { default as zhCN } from "./zh-CN.json"; diff --git a/desktop/renderer/src/locales/zh-CN.json b/desktop/renderer/src/locales/zh-CN.json new file mode 100644 index 0000000..eae77cf --- /dev/null +++ b/desktop/renderer/src/locales/zh-CN.json @@ -0,0 +1,10 @@ +{ + "settings": { + "appearance": "外观", + "chinese": "中文", + "language": "语言", + "theme-light": "浅色", + "theme-dark": "深色", + "theme-auto": "自动" + } +} diff --git a/desktop/renderer/src/misc/utils.ts b/desktop/renderer/src/misc/utils.ts new file mode 100644 index 0000000..600659b --- /dev/null +++ b/desktop/renderer/src/misc/utils.ts @@ -0,0 +1,22 @@ +export const isPromise = ( + value: T | PromiseLike, +): value is PromiseLike => + value && typeof (value as PromiseLike).then === "function"; + +export const noop = () => {}; + +const invoke = (fn?: (() => unknown) | null): void => { + try { + fn?.(); + } + catch (e) { + console.error(e); + } +}; + +export const joinDisposers + = ( + ...disposers: ReadonlyArray<(() => void) | undefined | null> + ): (() => void) => + () => + disposers.forEach(invoke); diff --git a/desktop/renderer/src/routes/Settings/index.tsx b/desktop/renderer/src/routes/Settings/index.tsx index c667831..b4243c8 100644 --- a/desktop/renderer/src/routes/Settings/index.tsx +++ b/desktop/renderer/src/routes/Settings/index.tsx @@ -1,55 +1,63 @@ import type { CheckboxChangeEvent } from "antd/es/checkbox"; +import { Radio } from "antd"; import React from "react"; - import { useVal } from "use-value-enhancer"; +import { useLang, useTranslate } from "val-i18n-react"; import { AppearancePicker } from "~/components/AppearancePicker"; import type { OOMOLPrefersColorScheme } from "~/components/ThemeProvider"; import { useAppContext } from "~/hooks"; - import styles from "./index.module.scss"; +enum SelectLanguage { + Chinese = "zh-CN", + English = "en", +} + +type Lang = "en" | "zh-CN"; + export const Settings = () => { - // const t = useTranslate(); - // const language = useLang(); + const t = useTranslate(); + const language = useLang(); const { settingStore } = useAppContext(); const prefersColorScheme = useVal(settingStore.prefersColorScheme$); - // const [selectLanguage, setSelectLanguage] = useState(language as Lang); - const changeAppearance = (event: CheckboxChangeEvent) => { const prefersColorScheme: OOMOLPrefersColorScheme = event.target.value; settingStore.updatePrefersColorScheme(prefersColorScheme); }; + const changeLanguage = async (event: CheckboxChangeEvent) => { + const lang: Lang = event.target.value; + settingStore.updateLocalLanguage(lang); + }; + return (
- {/*

{t("setting.appearance")}

*/} -

Appearance

+

{t("settings.appearance")}

- {/*
*/} - {/* {t("setting.language")} */} - {/* + {t("settings.language")} + - {t("setting.chinese")} + {t("settings.chinese")} English - */} - {/*
*/} + +
); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 329457b..c8d1342 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,12 @@ importers: use-value-enhancer: specifier: ^5.0.6 version: 5.0.6(react@18.3.1)(value-enhancer@5.5.0) + val-i18n: + specifier: ^0.1.11 + version: 0.1.13(value-enhancer@5.5.0) + val-i18n-react: + specifier: ^0.1.5 + version: 0.1.5(react@18.3.1)(use-value-enhancer@5.0.6(react@18.3.1)(value-enhancer@5.5.0))(val-i18n@0.1.13(value-enhancer@5.5.0)) value-enhancer: specifier: ^5.4.2 version: 5.5.0 @@ -3272,6 +3278,18 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + val-i18n-react@0.1.5: + resolution: {integrity: sha512-9zeY1l2dy2nsfK6GrQkExRYa2614jDumwWChD/BfkPWnUtDmsJdg45GwayYJBex2cJ8NC4TfN5MkE6JJMr8Hxg==} + peerDependencies: + react: '>=16' + use-value-enhancer: ^5.0.6 + val-i18n: '0' + + val-i18n@0.1.13: + resolution: {integrity: sha512-B0JtosN6u5E89MihWuv2jYKLp56ziuyMHomUpyDQFEtKDotu44UV/0MdV9Xkh7W1e1hHTq+eUkKgt+838No84A==} + peerDependencies: + value-enhancer: ^5.4.2 + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -6900,6 +6918,16 @@ snapshots: util-deprecate@1.0.2: {} + val-i18n-react@0.1.5(react@18.3.1)(use-value-enhancer@5.0.6(react@18.3.1)(value-enhancer@5.5.0))(val-i18n@0.1.13(value-enhancer@5.5.0)): + dependencies: + react: 18.3.1 + use-value-enhancer: 5.0.6(react@18.3.1)(value-enhancer@5.5.0) + val-i18n: 0.1.13(value-enhancer@5.5.0) + + val-i18n@0.1.13(value-enhancer@5.5.0): + dependencies: + value-enhancer: 5.5.0 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0