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
40 changes: 39 additions & 1 deletion src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Dialog } from '@clickhouse/click-ui';
import { Dialog, Select } from '@clickhouse/click-ui';
import { useTranslation } from 'react-i18next';
import type * as t from '@/types';
import { useTheme } from '@/contexts/ThemeContext';
import { useLocalize } from '@/hooks';
Expand All @@ -11,9 +12,26 @@ const THEME_LABEL_KEYS: Record<t.ThemeOption, string> = {
dark: 'com_nav_theme_dark',
};

const LANGUAGE_OPTIONS = ['en', 'zh-Hans'] as const;
type LanguageOption = (typeof LANGUAGE_OPTIONS)[number];
const LANGUAGE_LABEL_KEYS: Record<LanguageOption, string> = {
en: 'com_ui_language_english',
'zh-Hans': 'com_ui_language_zh_hans',
};

export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) {
const localize = useLocalize();
const { theme, setTheme } = useTheme();
const { i18n } = useTranslation();

const resolvedLanguage = i18n.resolvedLanguage ?? i18n.language ?? 'en';
const currentLanguage: LanguageOption = resolvedLanguage.startsWith('zh') ? 'zh-Hans' : 'en';

const setLanguage = (language: LanguageOption) => {
i18n.changeLanguage(language);
localStorage.setItem('i18nextLng', language);
document.documentElement.lang = language;
};

return (
<Dialog
Expand Down Expand Up @@ -57,6 +75,26 @@ export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) {
))}
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-(--cui-color-text-default)">
{localize('com_ui_language')}
</span>
</div>
<div className="w-40 shrink-0">
<Select
value={currentLanguage}
onSelect={(v) => setLanguage(v as LanguageOption)}
aria-label={localize('com_ui_language')}
>
{LANGUAGE_OPTIONS.map((opt) => (
<Select.Item key={opt} value={opt}>
{localize(LANGUAGE_LABEL_KEYS[opt])}
</Select.Item>
))}
</Select>
</div>
</div>
</div>
</Dialog.Content>
</Dialog>
Expand Down
7 changes: 6 additions & 1 deletion src/components/grants/AuditLogTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ const TARGET_TYPE_OPTIONS: readonly PrincipalType[] = [
PrincipalType.GROUP,
PrincipalType.ROLE,
] as const;
const TARGET_TYPE_LABEL_KEYS: Record<(typeof TARGET_TYPE_OPTIONS)[number], string> = {
[PrincipalType.USER]: 'com_audit_target_user',
[PrincipalType.GROUP]: 'com_audit_target_group',
[PrincipalType.ROLE]: 'com_audit_target_role',
};
/** Radix `Select.Item` cannot use `value=""` (Radix reserves empty string for
* "no selection"). Use a non-empty sentinel and translate to `''` in state. */
const TARGET_TYPE_ALL = '__all__';
Expand Down Expand Up @@ -510,7 +515,7 @@ export function AuditLogTab() {
<Select.Item value={TARGET_TYPE_ALL}>{localize('com_ui_all')}</Select.Item>
{TARGET_TYPE_OPTIONS.map((pt) => (
<Select.Item key={pt} value={pt}>
{pt}
{localize(TARGET_TYPE_LABEL_KEYS[pt])}
</Select.Item>
))}
</Select>
Expand Down
4 changes: 2 additions & 2 deletions src/components/users/CreateUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ export function CreateUserDialog({ open, onClose }: t.CreateUserDialogProps) {
onChange={(e) => setRole(e.target.value as SystemRoles)}
className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-default) px-3 py-2 text-sm text-(--cui-color-text-default)"
>
<option value={SystemRoles.USER}>{SystemRoles.USER}</option>
<option value={SystemRoles.ADMIN}>{SystemRoles.ADMIN}</option>
<option value={SystemRoles.USER}>{localize('com_users_role_user')}</option>
<option value={SystemRoles.ADMIN}>{localize('com_users_role_admin')}</option>
</select>
</div>
</FormDialog>
Expand Down
10 changes: 9 additions & 1 deletion src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1247,5 +1247,13 @@
"com_kv_type_number": "123",
"com_kv_type_boolean": "T/F",
"com_kv_type_json": "{ }",
"com_a11y_logo_alt": "LibreChat logo"
"com_a11y_logo_alt": "LibreChat logo",
"com_ui_language": "Language",
"com_ui_language_english": "English",
"com_ui_language_zh_hans": "中文(简体)",
"com_audit_target_user": "User",
"com_audit_target_group": "Group",
"com_audit_target_role": "Role",
"com_users_role_admin": "Administrator",
"com_users_role_user": "User"
}
79 changes: 64 additions & 15 deletions src/locales/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,76 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import translationEn from './en/translation.json';
import translationZhHans from './zh-Hans/translation.json';

export const defaultNS = 'translation';

export const supportedLocales = ['en', 'zh-Hans'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];

export const resources = {
en: { translation: translationEn },
'zh-Hans': { translation: translationZhHans },
} as const;

i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: {
default: ['en'],
},
fallbackNS: 'translation',
ns: ['translation'],
debug: false,
defaultNS,
resources,
interpolation: { escapeValue: false },
});
const supportedByLowercase: Record<string, SupportedLocale> = Object.fromEntries(
supportedLocales.map((locale) => [locale.toLowerCase(), locale]),
);

// Mirrors LibreChat's client locale normalization: exact supported match first,
// then alias table, then the base-language entry.
const localeAliases: Record<string, SupportedLocale> = {
zh: 'zh-Hans',
'zh-cn': 'zh-Hans',
'zh-sg': 'zh-Hans',
};

export function normalizeLocale(locale?: string | null): SupportedLocale {
if (!locale) {
return 'en';
}
const normalized = locale.replace(/_/g, '-').toLowerCase();
const exact = supportedByLowercase[normalized];
if (exact) {
return exact;
}
return localeAliases[normalized] ?? localeAliases[normalized.split('-')[0]] ?? 'en';
}

// A stored choice (i18nextLng, written by the settings language selector) wins;
// otherwise fall back to the browser language. SSR has no reliable locale
// signal (Bun defines `navigator` but leaves `language` empty), so it always
// renders the default locale and the browser takes over on hydration.
export function detectInitialLanguage(): SupportedLocale {
if (typeof window === 'undefined') {
return 'en';
}
const stored = localStorage.getItem('i18nextLng');
if (stored) {
return normalizeLocale(stored);
}
return normalizeLocale(navigator.language || navigator.languages?.[0]);
}

const initialLanguage = detectInitialLanguage();

i18n.use(initReactI18next).init({
lng: initialLanguage,
supportedLngs: [...supportedLocales],
fallbackLng: {
zh: ['zh-Hans'],
default: ['en'],
},
fallbackNS: 'translation',
ns: ['translation'],
debug: false,
defaultNS,
resources,
interpolation: { escapeValue: false },
});

if (typeof document !== 'undefined') {
document.documentElement.lang = initialLanguage;
}

export default i18n;
65 changes: 65 additions & 0 deletions src/locales/locales.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Guards locale parity between en and zh-Hans: identical key sets,
* matching interpolation placeholders, and a working language switch.
*/
import { describe, it, expect } from 'vitest';
import i18n, { normalizeLocale, detectInitialLanguage } from './i18n';
import en from './en/translation.json';
import zhHans from './zh-Hans/translation.json';

const enKeys = Object.keys(en).sort();
const zhKeys = Object.keys(zhHans).sort();

describe('locale parity (en / zh-Hans)', () => {
it('zh-Hans defines the same key set as en', () => {
expect(zhKeys).toEqual(enKeys);
});

it('interpolation placeholders match en for every key', () => {
const re = /\{\{[^}]+\}\}/g;
const mismatches = enKeys.filter(
(key) =>
(String(en[key as keyof typeof en]).match(re) ?? []).sort().join() !==
(String(zhHans[key as keyof typeof zhHans]).match(re) ?? []).sort().join(),
);
expect(mismatches).toHaveLength(0);
});

it('switches to zh-Hans and back', async () => {
await i18n.changeLanguage('zh-Hans');
expect(i18n.t('com_ui_save')).toBe(zhHans['com_ui_save']);
await i18n.changeLanguage('en');
expect(i18n.t('com_ui_save')).toBe(en['com_ui_save']);
});

it('resolves browser-style zh codes to zh-Hans', async () => {
await i18n.changeLanguage('zh-CN');
expect(i18n.t('com_ui_save')).toBe(zhHans['com_ui_save']);
await i18n.changeLanguage('zh');
expect(i18n.t('com_ui_save')).toBe(zhHans['com_ui_save']);
await i18n.changeLanguage('en');
expect(i18n.t('com_ui_save')).toBe(en['com_ui_save']);
});
});

describe('locale normalization (LibreChat-style)', () => {
it('maps exact, alias, and base-language codes to supported locales', () => {
expect(normalizeLocale(null)).toBe('en');
expect(normalizeLocale('en')).toBe('en');
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('zh-CN')).toBe('zh-Hans');
expect(normalizeLocale('zh')).toBe('zh-Hans');
expect(normalizeLocale('zh-Hans')).toBe('zh-Hans');
expect(normalizeLocale('zh_TW')).toBe('zh-Hans');
expect(normalizeLocale('zh-TW')).toBe('zh-Hans');
expect(normalizeLocale('fr')).toBe('en');
});

it('prefers a stored choice over the browser language', () => {
localStorage.setItem('i18nextLng', 'zh-Hans');
expect(detectInitialLanguage()).toBe('zh-Hans');
localStorage.setItem('i18nextLng', 'en');
expect(detectInitialLanguage()).toBe('en');
localStorage.removeItem('i18nextLng');
});
});
Loading