From ee43646782a42e4d53fc13d915cbf9859221c038 Mon Sep 17 00:00:00 2001 From: Ruhollah Date: Fri, 30 Oct 2026 17:49:32 +0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=90=20feat:=20Add=20Persian=20Translat?= =?UTF-8?q?ion=20with=20Right-to-Left=20Layout=20and=20Language=20Switcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete fa locale, a language choice persisted in localStorage (settings dialog, command menu and login page), and renders from the active language so React 19 does not reset them. Physical-direction Tailwind classes and CSS properties are converted to logical ones, drawer and switch animations mirror in RTL, Vazirmatn is bundled locally for Persian, and audit/user dates format in the UI language. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 + package.json | 1 + src/components/CommandMenu.tsx | 21 +- src/components/Header.tsx | 2 +- src/components/InputOTP.tsx | 4 +- src/components/LanguageSelector.tsx | 24 + src/components/SettingsDialog.tsx | 33 +- src/components/Sidebar.tsx | 2 +- src/components/ThemeSelector.tsx | 2 +- src/components/access/GroupsTab.tsx | 4 +- .../access/RolePermissionsPanel.tsx | 2 +- src/components/access/RolesTab.tsx | 4 +- .../configuration/ConfigLoadError.tsx | 2 +- src/components/configuration/ConfigPage.tsx | 2 +- .../configuration/ConfigSkeleton.tsx | 2 +- .../configuration/ConfigTableOfContents.tsx | 8 +- .../configuration/ContentToolbar.tsx | 6 +- src/components/configuration/ScopeBar.tsx | 2 +- .../configuration/ScopeOverridesDialog.tsx | 2 +- .../configuration/fields/MimeListField.tsx | 2 +- .../configuration/fields/MultiEnumField.tsx | 2 +- .../configuration/fields/NumberField.tsx | 2 +- .../configuration/fields/ObjectEntryCard.tsx | 4 +- .../configuration/fields/SecretField.tsx | 4 +- .../configuration/fields/SelectField.tsx | 4 +- .../fields/SwitchObjectField.tsx | 2 +- .../configuration/fields/VariantField.tsx | 2 +- .../configuration/preview/Drawer.tsx | 4 +- .../configuration/renderer/InlineField.tsx | 4 +- .../configuration/review/ChangeReview.tsx | 4 +- .../configuration/review/ChangeRow.tsx | 2 +- .../configuration/review/ReviewList.tsx | 2 +- .../sections/LangfuseRenderer.tsx | 2 +- .../sections/endpoints/Create.tsx | 4 +- .../endpoints/CustomEndpointsRenderer.tsx | 4 +- .../sections/endpoints/Drawer.tsx | 4 +- .../configuration/sections/endpoints/List.tsx | 2 +- .../configuration/sections/endpoints/Nav.tsx | 2 +- .../sections/endpoints/Pricing.tsx | 8 +- .../sections/files/StorageConfirm.tsx | 2 +- .../configuration/sections/mcp/Card.tsx | 6 +- .../configuration/sections/mcp/Create.tsx | 2 +- .../configuration/sections/mcp/Servers.tsx | 2 +- .../sections/providers/AzureGroupDrawer.tsx | 4 +- .../sections/providers/AzureGroups.tsx | 2 +- .../sections/specs/ChipPicker.tsx | 4 +- .../sections/specs/GroupedSelect.tsx | 4 +- .../configuration/sections/specs/List.tsx | 2 +- .../sections/specs/PresetEditor.tsx | 4 +- .../sections/system/CreditsInput.tsx | 2 +- .../sections/system/RegistrationRenderer.tsx | 2 +- .../configuration/wizard/Preview.tsx | 8 +- .../configuration/wizard/Targets.tsx | 8 +- .../grants/AuditLogDetailDrawer.tsx | 21 +- src/components/grants/AuditLogTab.tsx | 4 +- src/components/grants/AuditLogTableRow.tsx | 5 +- src/components/grants/CapabilityPanel.tsx | 2 +- src/components/grants/GrantManagementTab.tsx | 4 +- src/components/grants/rows.ts | 2 +- src/components/help/HelpPage.tsx | 4 +- src/components/shared/DiscardConfirm.tsx | 2 +- src/components/shared/ReorderableList.tsx | 4 +- src/components/shared/SelectAllHeader.tsx | 6 +- src/components/shared/StickyActionBar.tsx | 2 +- src/components/shared/UserSearchInline.tsx | 4 +- src/components/users/UsersPage.tsx | 13 +- src/constants/index.ts | 1 + src/constants/locale.ts | 12 + src/hooks/index.ts | 1 + src/hooks/language.ts | 25 + src/locales/en/translation.json | 6 +- src/locales/fa.test.ts | 58 + src/locales/fa/translation.json | 3756 +++++++++++++++++ src/locales/i18n.ts | 23 +- src/routes/__root.tsx | 11 +- src/routes/_app.tsx | 2 +- src/routes/login.tsx | 4 +- src/styles.css | 97 +- src/types/index.ts | 1 + src/types/locale.ts | 7 + 80 files changed, 4163 insertions(+), 156 deletions(-) create mode 100644 src/components/LanguageSelector.tsx create mode 100644 src/constants/locale.ts create mode 100644 src/hooks/language.ts create mode 100644 src/locales/fa.test.ts create mode 100644 src/locales/fa/translation.json create mode 100644 src/types/locale.ts diff --git a/bun.lock b/bun.lock index a792c316..a6da890c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "librechat-admin-panel", "dependencies": { "@clickhouse/click-ui": "0.15.0", + "@fontsource-variable/vazirmatn": "^5.3.0", "@librechat/data-schemas": "^0.0.74", "@radix-ui/react-dialog": "1.3.1", "@tailwindcss/vite": "^4.3.3", @@ -231,6 +232,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@fontsource-variable/vazirmatn": ["@fontsource-variable/vazirmatn@5.3.0", "", {}, "sha512-ZA68W8JHbMdNzzwjYHUWKB2PH22QpxNEnk0ukcXM+XiqYMV6ZWJZAKuNXcrMR2JjuIAwsfAU3KknCXPI6wXUvw=="], + "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], diff --git a/package.json b/package.json index 7f414e16..f1d2c0c0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@clickhouse/click-ui": "0.15.0", + "@fontsource-variable/vazirmatn": "^5.3.0", "@librechat/data-schemas": "^0.0.74", "@radix-ui/react-dialog": "1.3.1", "@tailwindcss/vite": "^4.3.3", diff --git a/src/components/CommandMenu.tsx b/src/components/CommandMenu.tsx index 870f15ff..925b57cb 100644 --- a/src/components/CommandMenu.tsx +++ b/src/components/CommandMenu.tsx @@ -6,8 +6,9 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { Title as DialogTitle, Description as DialogDescription } from '@radix-ui/react-dialog'; import type * as t from '@/types'; import { matchesQuery, searchConfigIndex } from './configuration/search'; -import { useSearchIndex, useLocalize, useReturnFocus } from '@/hooks'; +import { useSearchIndex, useLocalize, useLanguage, useReturnFocus } from '@/hooks'; import { CONFIG_TABS } from './configuration/configMeta'; +import { LANGUAGES } from '@/constants'; import { pageTitle, restoreFocusLater } from '@/utils'; import { CommandDialog } from '@/components/shared'; import { useTheme } from '@/contexts/ThemeContext'; @@ -24,6 +25,7 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { const localize = useLocalize(); const router = useRouter(); const { setTheme } = useTheme(); + const { setLanguage } = useLanguage(); const { items: settings } = useSearchIndex(localize, open); const [search, setSearch] = useState(''); @@ -111,6 +113,14 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { [setTheme, close], ); + const selectLanguage = useCallback( + (value: t.LanguageCode) => { + setLanguage(value); + close(); + }, + [setLanguage, close], + ); + const navigation: t.StaticCommand[] = [ { id: 'nav-home', @@ -190,6 +200,15 @@ export function CommandMenu({ open, onOpenChange }: t.CommandMenuProps) { keywords: ['theme', 'system', 'auto', 'mode', 'appearance'], onSelect: () => selectTheme('system'), }, + ...LANGUAGES.map( + (option): t.StaticCommand => ({ + id: `language-${option.code}`, + icon: 'globe', + label: localize('com_cmdk_set_language', { language: option.nativeName }), + keywords: ['language', 'locale', 'translation', option.code, option.nativeName], + onSelect: () => selectLanguage(option.code), + }), + ), ]; const query = search.trim(); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 04bbdaf8..1e3afd2b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -32,7 +32,7 @@ export function Header({ title, onSearchClick, children }: t.HeaderProps) {
)}
-
+
{children}
{onSearchClick && ( diff --git a/src/components/InputOTP.tsx b/src/components/InputOTP.tsx index a1640714..64766cbe 100644 --- a/src/components/InputOTP.tsx +++ b/src/components/InputOTP.tsx @@ -46,10 +46,10 @@ const InputOTPSlot = React.forwardRef< ref={ref} className={clsx( 'relative flex h-12 w-11 items-center justify-center', - 'border-y border-r border-(--cui-color-stroke-default)', + 'border-y border-e border-(--cui-color-stroke-default)', 'bg-(--cui-color-background-default) text-(--cui-color-text-default)', 'text-lg font-medium shadow-sm transition-all', - 'first:rounded-l-lg first:border-l last:rounded-r-lg', + 'first:rounded-s-lg first:border-s last:rounded-e-lg', isActive ? 'z-10 border-(--cui-color-outline) ring-1 ring-(--cui-color-outline)' : 'hover:border-(--cui-color-stroke-intense)', diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx new file mode 100644 index 00000000..11748d30 --- /dev/null +++ b/src/components/LanguageSelector.tsx @@ -0,0 +1,24 @@ +import { Icon } from '@clickhouse/click-ui'; +import { useLocalize, useLanguage } from '@/hooks'; +import { LANGUAGES } from '@/constants'; + +/** Switches to the next shipped language, labeled with that language's own name. */ +export function LanguageSelector() { + const localize = useLocalize(); + const { language, setLanguage } = useLanguage(); + const index = LANGUAGES.findIndex((option) => option.code === language); + const next = LANGUAGES[(index + 1) % LANGUAGES.length]; + + return ( + + ); +} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 96d02d8b..e9716e84 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -1,7 +1,8 @@ import { Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { useLocalize, useReturnFocus } from '@/hooks'; +import { useLocalize, useLanguage, useReturnFocus } from '@/hooks'; import { useTheme } from '@/contexts/ThemeContext'; +import { LANGUAGES } from '@/constants'; import { cn } from '@/utils'; const THEME_OPTIONS: t.ThemeOption[] = ['system', 'light', 'dark']; @@ -15,6 +16,7 @@ export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { const localize = useLocalize(); const returnFocus = useReturnFocus(open); const { theme, setTheme } = useTheme(); + const { language, setLanguage } = useLanguage(); return (
+
+
+ + {localize('com_nav_language')} + + + {localize('com_settings_language_desc')} + +
+
+ {LANGUAGES.map((opt) => ( + + ))} +
+
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a21b0004..e26d8be1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -128,7 +128,7 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) {