Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/desktop/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@
/**
* Returns the locale-appropriate string set based on `navigator.language`.
* Falls back to English for any non-Chinese locale.
* When `zh-TW` or `zh-HK` is detected and a `"zh-TW"` key exists, it is
* preferred; otherwise falls back to `zh`.
*
* `en` and `zh` must share the same structural shape — string values may differ.
*/
export function resolveLocale<T>(strings: { en: T; zh: T }): T {
return navigator.language.startsWith("zh") ? strings.zh : strings.en;
export function resolveLocale<T>(strings: {
en: T;
zh: T;
"zh-TW"?: T;
}): T {
const lang = navigator.language;
if (lang === "zh-TW" || lang === "zh-HK") {
return strings["zh-TW"] ?? strings.zh;
}
return lang.startsWith("zh") ? strings.zh : strings.en;
}
3 changes: 2 additions & 1 deletion apps/web/src/components/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function LanguageSwitcher({ variant = "light", size = "sm" }: Props) {

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

const currentLabel =
Expand Down
36 changes: 29 additions & 7 deletions apps/web/src/hooks/use-locale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
patchApiInternalDesktopPreferences,
} from "../../lib/api/sdk.gen";

export type Locale = "en" | "zh";
export type Locale = "en" | "zh" | "zh-TW";

interface LocaleCtx {
locale: Locale;
Expand All @@ -26,12 +26,15 @@ const STORAGE_KEY = "nexu_locale";
function detectDefault(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored;
if (stored === "en" || stored === "zh" || stored === "zh-TW")
return stored;
} catch {
/* ignore */
}
const lang = navigator.language || "";
return lang.startsWith("zh") ? "zh" : "en";
if (lang === "zh-TW" || lang === "zh-HK") return "zh-TW";
if (lang.startsWith("zh")) return "zh";
return "en";
}

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

useEffect(() => {
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
const langMap: Record<Locale, string> = {
en: "en",
zh: "zh-CN",
"zh-TW": "zh-TW",
};
document.documentElement.lang = langMap[locale];
}, [locale]);

useEffect(() => {
Expand Down Expand Up @@ -92,9 +100,14 @@ export function useLocale() {
}

async function syncDesktopLocale(locale: Locale): Promise<void> {
const apiLocaleMap: Record<Locale, string> = {
en: "en",
zh: "zh-CN",
"zh-TW": "zh-TW",
};
Comment on lines +103 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist zh-TW using a controller-supported locale

syncDesktopLocale() now posts "zh-TW" to /api/internal/desktop/preferences, but the controller endpoint and store still only accept "en" | "zh-CN" (see apps/controller/src/routes/desktop-routes.ts and apps/controller/src/store/nexu-config-store.ts). For users who pick Traditional Chinese (or are auto-detected as zh-TW/zh-HK), that PATCH call fails and is swallowed, so the desktop preference never persists and controller-side locale-dependent behavior stays on the old locale. This breaks the claimed zh-TW round-trip and should be fixed by mapping zh-TW to a supported persisted value or by extending backend locale enums first.

Useful? React with 👍 / 👎.

await patchApiInternalDesktopPreferences({
body: {
locale: locale === "zh" ? "zh-CN" : "en",
locale: apiLocaleMap[locale],
},
}).catch(() => {
// Best-effort sync only; local UI language should still work offline.
Expand All @@ -118,8 +131,17 @@ async function bootstrapLocale(
return;
}

if (storedLocale === "en" || storedLocale === "zh-CN") {
const nextLocale = storedLocale === "zh-CN" ? "zh" : "en";
if (
storedLocale === "en" ||
storedLocale === "zh-CN" ||
storedLocale === "zh-TW"
) {
const nextLocale: Locale =
storedLocale === "zh-CN"
? "zh"
: storedLocale === "zh-TW"
? "zh-TW"
: "en";
await i18n.changeLanguage(nextLocale);
setLocaleState(nextLocale);
try {
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,28 @@ import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import en from "./locales/en";
import zhCN from "./locales/zh-CN";
import zhTW from "./locales/zh-TW";

const STORAGE_KEY = "nexu_locale";

function detectLocale(): string {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored;
if (stored === "en" || stored === "zh" || stored === "zh-TW") return stored;
} catch {
/* ignore */
}
const lang = navigator.language || "";
return lang.startsWith("zh") ? "zh" : "en";
if (lang === "zh-TW" || lang === "zh-HK") return "zh-TW";
if (lang.startsWith("zh")) return "zh";
return "en";
}

i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zhCN },
"zh-TW": { translation: zhTW },
},
lng: detectLocale(),
fallbackLng: "en",
Expand Down
Loading
Loading