Skip to content

Commit a053467

Browse files
author
caiqiling958-cmd
committed
feat(i18n): add Traditional Chinese (zh-TW) locale support
Add full Traditional Chinese (繁體中文) support to the client UI. - Create `zh-TW.ts` translation file (converted from zh-CN via OpenCC s2twp) - Register zh-TW resource in i18next init - Extend Locale type to include "zh-TW" - Auto-detect zh-TW/zh-HK browser locale - Add "繁體中文" option to language switcher and settings page - Update desktop renderer i18n helper with zh-TW fallback Closes #1120 Made-with: Cursor
1 parent 35d140f commit a053467

6 files changed

Lines changed: 1274 additions & 15 deletions

File tree

apps/desktop/src/lib/i18n.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@
1010
/**
1111
* Returns the locale-appropriate string set based on `navigator.language`.
1212
* Falls back to English for any non-Chinese locale.
13+
* When `zh-TW` or `zh-HK` is detected and a `"zh-TW"` key exists, it is
14+
* preferred; otherwise falls back to `zh`.
1315
*
1416
* `en` and `zh` must share the same structural shape — string values may differ.
1517
*/
16-
export function resolveLocale<T>(strings: { en: T; zh: T }): T {
17-
return navigator.language.startsWith("zh") ? strings.zh : strings.en;
18+
export function resolveLocale<T>(strings: {
19+
en: T;
20+
zh: T;
21+
"zh-TW"?: T;
22+
}): T {
23+
const lang = navigator.language;
24+
if (lang === "zh-TW" || lang === "zh-HK") {
25+
return strings["zh-TW"] ?? strings.zh;
26+
}
27+
return lang.startsWith("zh") ? strings.zh : strings.en;
1828
}

apps/web/src/components/language-switcher.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ export function LanguageSwitcher({ variant = "light", size = "sm" }: Props) {
3838

3939
const options: Array<{ value: Locale; label: string }> = [
4040
{ value: "en", label: "English" },
41-
{ value: "zh", label: "中文" },
41+
{ value: "zh", label: "简体中文" },
42+
{ value: "zh-TW", label: "繁體中文" },
4243
];
4344

4445
const currentLabel =

apps/web/src/hooks/use-locale.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
patchApiInternalDesktopPreferences,
1414
} from "../../lib/api/sdk.gen";
1515

16-
export type Locale = "en" | "zh";
16+
export type Locale = "en" | "zh" | "zh-TW";
1717

1818
interface LocaleCtx {
1919
locale: Locale;
@@ -26,12 +26,15 @@ const STORAGE_KEY = "nexu_locale";
2626
function detectDefault(): Locale {
2727
try {
2828
const stored = localStorage.getItem(STORAGE_KEY);
29-
if (stored === "en" || stored === "zh") return stored;
29+
if (stored === "en" || stored === "zh" || stored === "zh-TW")
30+
return stored;
3031
} catch {
3132
/* ignore */
3233
}
3334
const lang = navigator.language || "";
34-
return lang.startsWith("zh") ? "zh" : "en";
35+
if (lang === "zh-TW" || lang === "zh-HK") return "zh-TW";
36+
if (lang.startsWith("zh")) return "zh";
37+
return "en";
3538
}
3639

3740
const LocaleContext = createContext<LocaleCtx>({
@@ -49,7 +52,12 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
4952
const userInteractedRef = useRef(false);
5053

5154
useEffect(() => {
52-
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
55+
const langMap: Record<Locale, string> = {
56+
en: "en",
57+
zh: "zh-CN",
58+
"zh-TW": "zh-TW",
59+
};
60+
document.documentElement.lang = langMap[locale];
5361
}, [locale]);
5462

5563
useEffect(() => {
@@ -92,9 +100,14 @@ export function useLocale() {
92100
}
93101

94102
async function syncDesktopLocale(locale: Locale): Promise<void> {
103+
const apiLocaleMap: Record<Locale, string> = {
104+
en: "en",
105+
zh: "zh-CN",
106+
"zh-TW": "zh-TW",
107+
};
95108
await patchApiInternalDesktopPreferences({
96109
body: {
97-
locale: locale === "zh" ? "zh-CN" : "en",
110+
locale: apiLocaleMap[locale],
98111
},
99112
}).catch(() => {
100113
// Best-effort sync only; local UI language should still work offline.
@@ -118,8 +131,17 @@ async function bootstrapLocale(
118131
return;
119132
}
120133

121-
if (storedLocale === "en" || storedLocale === "zh-CN") {
122-
const nextLocale = storedLocale === "zh-CN" ? "zh" : "en";
134+
if (
135+
storedLocale === "en" ||
136+
storedLocale === "zh-CN" ||
137+
storedLocale === "zh-TW"
138+
) {
139+
const nextLocale: Locale =
140+
storedLocale === "zh-CN"
141+
? "zh"
142+
: storedLocale === "zh-TW"
143+
? "zh-TW"
144+
: "en";
123145
await i18n.changeLanguage(nextLocale);
124146
setLocaleState(nextLocale);
125147
try {

apps/web/src/i18n/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,28 @@ import i18n from "i18next";
22
import { initReactI18next } from "react-i18next";
33
import en from "./locales/en";
44
import zhCN from "./locales/zh-CN";
5+
import zhTW from "./locales/zh-TW";
56

67
const STORAGE_KEY = "nexu_locale";
78

89
function detectLocale(): string {
910
try {
1011
const stored = localStorage.getItem(STORAGE_KEY);
11-
if (stored === "en" || stored === "zh") return stored;
12+
if (stored === "en" || stored === "zh" || stored === "zh-TW") return stored;
1213
} catch {
1314
/* ignore */
1415
}
1516
const lang = navigator.language || "";
16-
return lang.startsWith("zh") ? "zh" : "en";
17+
if (lang === "zh-TW" || lang === "zh-HK") return "zh-TW";
18+
if (lang.startsWith("zh")) return "zh";
19+
return "en";
1720
}
1821

1922
i18n.use(initReactI18next).init({
2023
resources: {
2124
en: { translation: en },
2225
zh: { translation: zhCN },
26+
"zh-TW": { translation: zhTW },
2327
},
2428
lng: detectLocale(),
2529
fallbackLng: "en",

0 commit comments

Comments
 (0)