From 6c1add388ea07a150dfe57619ee810bb6f769622 Mon Sep 17 00:00:00 2001 From: 2331221 <98810877+2331221@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:10:01 +0800 Subject: [PATCH 1/2] feat(frontend): add zh-CN internationalization foundation --- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 60 ++++++++++++ .../src/components/layouts/main-sidebar.tsx | 52 ++++++---- .../src/components/shared/language-toggle.tsx | 30 ++++++ .../features/authentication/login-form.tsx | 95 ++++++++++--------- frontend/src/lib/i18n/README.md | 17 ++++ frontend/src/lib/i18n/en-US.ts | 53 +++++++++++ frontend/src/lib/i18n/index.test.ts | 24 +++++ frontend/src/lib/i18n/index.ts | 64 +++++++++++++ frontend/src/lib/i18n/zh-CN.ts | 53 +++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/dashboard/dashboard.tsx | 50 +++++----- frontend/src/pages/login.tsx | 4 +- 13 files changed, 417 insertions(+), 88 deletions(-) create mode 100644 frontend/src/components/shared/language-toggle.tsx create mode 100644 frontend/src/lib/i18n/README.md create mode 100644 frontend/src/lib/i18n/en-US.ts create mode 100644 frontend/src/lib/i18n/index.test.ts create mode 100644 frontend/src/lib/i18n/index.ts create mode 100644 frontend/src/lib/i18n/zh-CN.ts diff --git a/frontend/package.json b/frontend/package.json index 48d94e4d..56bf3997 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,6 +64,7 @@ "graphql": "^16.14.0", "graphql-ws": "^6.0.8", "highlight.js": "^11.11.1", + "i18next": "25.6.3", "js-cookie": "^3.0.7", "lodash": "^4.18.1", "lowlight": "^3.3.0", @@ -77,6 +78,7 @@ "react-diff-viewer-continued": "^4.2.2", "react-dom": "^19.2.6", "react-hook-form": "^7.76.0", + "react-i18next": "16.5.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.1", "react-router-dom": "^7.15.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 237fe8ea..0be3a898 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: highlight.js: specifier: ^11.11.1 version: 11.11.1 + i18next: + specifier: 25.6.3 + version: 25.6.3(typescript@6.0.3) js-cookie: specifier: ^3.0.7 version: 3.0.7 @@ -185,6 +188,9 @@ importers: react-hook-form: specifier: ^7.76.0 version: 7.76.0(react@19.2.6) + react-i18next: + specifier: 16.5.0 + version: 16.5.0(i18next@25.6.3(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.6) @@ -3943,6 +3949,9 @@ packages: engines: {node: '>=12'} hasBin: true + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -3956,6 +3965,14 @@ packages: hyphen@1.14.1: resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==} + i18next@25.6.3: + resolution: {integrity: sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -5054,6 +5071,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-i18next@16.5.0: + resolution: {integrity: sha512-IMpPTyCTKxEj8klCrLKUTIUa8uYTd851+jcu2fJuUB9Agkk9Qq8asw4omyeHVnOXHrLgQJGTm5zTvn8HpaPiqw==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5869,6 +5902,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -9992,6 +10029,10 @@ snapshots: relateurl: 0.2.7 terser: 5.47.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -10005,6 +10046,12 @@ snapshots: hyphen@1.14.1: {} + i18next@25.6.3(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 6.0.3 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -11267,6 +11314,17 @@ snapshots: dependencies: react: 19.2.6 + react-i18next@16.5.0(i18next@25.6.3(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 25.6.3(typescript@6.0.3) + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + typescript: 6.0.3 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -12183,6 +12241,8 @@ snapshots: transitivePeerDependencies: - msw + void-elements@3.1.0: {} + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: diff --git a/frontend/src/components/layouts/main-sidebar.tsx b/frontend/src/components/layouts/main-sidebar.tsx index 56041c6a..b3f4d1f3 100644 --- a/frontend/src/components/layouts/main-sidebar.tsx +++ b/frontend/src/components/layouts/main-sidebar.tsx @@ -6,6 +6,7 @@ import { Folder, GitFork, KeyRound, + Languages, LayoutDashboard, LibraryBig, LogOut, @@ -19,6 +20,7 @@ import { UserIcon, } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link, useMatch, useParams } from 'react-router-dom'; import type { Flow } from '@/providers/sidebar-flows-provider'; @@ -52,6 +54,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { PasswordChangeForm } from '@/features/authentication/password-change-form'; import { useResourcesUpload } from '@/features/resources/use-resources-upload'; import { useTheme } from '@/hooks/use-theme'; +import { normalizeLanguage } from '@/lib/i18n'; import { useFavorites } from '@/providers/favorites-provider'; import { useSidebarFlows } from '@/providers/sidebar-flows-provider'; import { useUser } from '@/providers/user-provider'; @@ -64,6 +67,7 @@ interface FlowMenuItemProps { } export function MainSidebar() { + const { i18n, t } = useTranslation(); const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); const isDashboardActive = useMatch('/dashboard'); const isFlowsActive = useMatch('/flows/*'); @@ -81,6 +85,9 @@ export function MainSidebar() { const resourcesUpload = useResourcesUpload(); + const currentLanguage = normalizeLanguage(i18n.resolvedLanguage || i18n.language); + const nextLanguage = currentLanguage === 'zh-CN' ? 'en-US' : 'zh-CN'; + const flowId = useMemo(() => (flowIdParam ? Number(flowIdParam) : null), [flowIdParam]); const favoriteFlows = useMemo( @@ -122,7 +129,7 @@ export function MainSidebar() { - New Flow + {t('navigation.newFlow')} @@ -133,7 +140,7 @@ export function MainSidebar() { > - Dashboard + {t('navigation.dashboard')} @@ -144,7 +151,7 @@ export function MainSidebar() { > - Flows + {t('navigation.flows')} - Templates + {t('navigation.templates')} - Resources + {t('navigation.resources')} @@ -204,7 +211,7 @@ export function MainSidebar() { > - Knowledges + {t('navigation.knowledges')} - Recent Flows + {t('navigation.recentFlows')} @@ -247,7 +254,7 @@ export function MainSidebar() { - Favorite Flows + {t('navigation.favoriteFlows')} @@ -274,7 +281,7 @@ export function MainSidebar() { > - Settings + {t('navigation.settings')} @@ -325,7 +332,7 @@ export function MainSidebar() { onSelect={(event) => event.preventDefault()} > - Theme + {t('navigation.theme')} setTheme(value as Theme)} @@ -333,21 +340,21 @@ export function MainSidebar() { > @@ -356,19 +363,26 @@ export function MainSidebar() { + void i18n.changeLanguage(nextLanguage)}> + + {t('language.label')} + + {t('language.action')} + + {user?.type === 'local' && ( <> setIsPasswordModalOpen(true)}> - Change Password + {t('navigation.changePassword')} )} logout()}> - Log out + {t('navigation.logOut')} @@ -394,7 +408,7 @@ export function MainSidebar() { > - Change Password + {t('navigation.changePassword')} setIsPasswordModalOpen(false)} @@ -407,6 +421,8 @@ export function MainSidebar() { } function FlowMenuItem({ activeFlowId, flow, isFavorite, onToggleFavorite }: FlowMenuItemProps) { + const { t } = useTranslation(); + return ( onToggleFavorite(flow.id)} diff --git a/frontend/src/components/shared/language-toggle.tsx b/frontend/src/components/shared/language-toggle.tsx new file mode 100644 index 00000000..7441c0dd --- /dev/null +++ b/frontend/src/components/shared/language-toggle.tsx @@ -0,0 +1,30 @@ +import { Languages } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { normalizeLanguage } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; + +interface LanguageToggleProps { + className?: string; +} + +export function LanguageToggle({ className }: LanguageToggleProps) { + const { i18n, t } = useTranslation(); + const currentLanguage = normalizeLanguage(i18n.resolvedLanguage || i18n.language); + const nextLanguage = currentLanguage === 'zh-CN' ? 'en-US' : 'zh-CN'; + + return ( + + ); +} diff --git a/frontend/src/features/authentication/login-form.tsx b/frontend/src/features/authentication/login-form.tsx index 16ae3bb7..256245d3 100644 --- a/frontend/src/features/authentication/login-form.tsx +++ b/frontend/src/features/authentication/login-form.tsx @@ -1,6 +1,9 @@ +import type { TFunction } from 'i18next'; + import { zodResolver } from '@hookform/resolvers/zod'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; @@ -16,25 +19,24 @@ import { useUser } from '@/providers/user-provider'; import { PasswordChangeForm } from './password-change-form'; -const formSchema = z.object({ - mail: z - .string() - .min(1, { - message: 'Login is required', - }) - .refine( - (value) => z.string().email().safeParse(value).success || ['admin', 'demo'].includes(value.toLowerCase()), - { - message: 'Invalid login', - }, - ), - password: z.string().min(1, { - message: 'Password is required', - }), -}); - -const errorMessage = 'Invalid login or password'; -const errorProviderMessage = 'Authentication failed'; +const createFormSchema = (t: TFunction) => + z.object({ + mail: z + .string() + .min(1, { + message: t('login.loginRequired'), + }) + .refine( + (value) => + z.string().email().safeParse(value).success || ['admin', 'demo'].includes(value.toLowerCase()), + { + message: t('login.invalidLogin'), + }, + ), + password: z.string().min(1, { + message: t('login.passwordRequired'), + }), + }); interface AuthProviderAction { icon: React.ReactNode; @@ -42,25 +44,26 @@ interface AuthProviderAction { name: string; } -const providerActions: AuthProviderAction[] = [ - { - icon: , - id: 'google', - name: 'Continue with Google', - }, - { - icon: , - id: 'github', - name: 'Continue with GitHub', - }, -]; - interface LoginFormProps { providers: string[]; // OAuth providers: ['google', 'github'] returnUrl?: string; } function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { + const { t } = useTranslation(); + const formSchema = useMemo(() => createFormSchema(t), [t]); + const providerActions: AuthProviderAction[] = [ + { + icon: , + id: 'google', + name: t('login.continueWithGoogle'), + }, + { + icon: , + id: 'github', + name: t('login.continueWithGithub'), + }, + ]; const form = useForm>({ defaultValues: { mail: '', @@ -81,7 +84,7 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { const result = await login(values); if (!result.success) { - setError(result.error || errorMessage); + setError(result.error || t('login.invalidLoginOrPassword')); return; } @@ -94,7 +97,7 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { navigate(returnUrl); } catch { - setError(errorMessage); + setError(t('login.invalidLoginOrPassword')); } }; @@ -106,14 +109,14 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { const result = await loginWithOAuth(provider); if (!result.success) { - setError(result.error || errorProviderMessage); + setError(result.error || t('login.authenticationFailed')); return; } navigate(returnUrl); } catch (error) { - setError(error instanceof Error ? error.message : errorMessage); + setError(error instanceof Error ? error.message : t('login.invalidLoginOrPassword')); } finally { setIsSubmitting(false); } @@ -155,10 +158,8 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { if (shouldShowPasswordChange) { return (
-

Update Password

-

- You need to change your password before continuing. -

+

{t('login.passwordUpdateTitle')}

+

{t('login.passwordUpdateDescription')}

- or + {t('login.or')}
@@ -213,12 +214,12 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { name="mail" render={({ field }) => ( - Login + {t('login.loginLabel')} @@ -231,11 +232,11 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { name="password" render={({ field }) => ( - Password + {t('login.passwordLabel')} @@ -245,7 +246,7 @@ function LoginForm({ providers, returnUrl = '/flows/new' }: LoginFormProps) { /> - Sign in + {t('login.signIn')} {error && {error}} diff --git a/frontend/src/lib/i18n/README.md b/frontend/src/lib/i18n/README.md new file mode 100644 index 00000000..439b7d3c --- /dev/null +++ b/frontend/src/lib/i18n/README.md @@ -0,0 +1,17 @@ +# Frontend internationalization + +PentAGI uses `i18next` and `react-i18next` for frontend UI localization. + +## Adding a language + +1. Add a translation resource beside `en-US.ts`. +2. Register it in `index.ts` and extend `SUPPORTED_LANGUAGES`. +3. Keep translation keys structurally identical to `en-US.ts`. +4. Use `useTranslation()` inside React components and call `t('namespace.key')`. + +English remains the fallback language. The selected language is stored under +`pentagi.language` in browser local storage, and the root document `lang` +attribute is updated whenever the language changes. + +Do not translate user content, terminal output, tool output, provider model +IDs, API field names, or security product names. diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts new file mode 100644 index 00000000..74e57ca1 --- /dev/null +++ b/frontend/src/lib/i18n/en-US.ts @@ -0,0 +1,53 @@ +const enUS = { + dashboard: { + analytics: 'Analytics', + breadcrumb: 'Dashboard', + month: 'Month', + overview: 'Overview', + quarter: 'Quarter', + week: 'Week', + }, + language: { + action: '中文', + label: 'Language', + switchAriaLabel: 'Switch language to Simplified Chinese', + }, + login: { + authenticationFailed: 'Authentication failed', + continueWithGithub: 'Continue with GitHub', + continueWithGoogle: 'Continue with Google', + emailPlaceholder: 'Enter your email', + invalidLogin: 'Invalid login', + invalidLoginOrPassword: 'Invalid login or password', + loginLabel: 'Login', + loginRequired: 'Login is required', + or: 'or', + passwordLabel: 'Password', + passwordPlaceholder: 'Enter your password', + passwordRequired: 'Password is required', + passwordUpdateDescription: 'You need to change your password before continuing.', + passwordUpdateTitle: 'Update Password', + signIn: 'Sign in', + }, + navigation: { + changePassword: 'Change Password', + dashboard: 'Dashboard', + favoriteFlows: 'Favorite Flows', + flows: 'Flows', + knowledges: 'Knowledges', + logOut: 'Log out', + newFlow: 'New Flow', + recentFlows: 'Recent Flows', + resources: 'Resources', + settings: 'Settings', + templates: 'Templates', + theme: 'Theme', + themeDark: 'Dark theme', + themeLight: 'Light theme', + themeSystem: 'System theme', + toggleFavorite: 'Toggle favorite', + uploadFile: 'Upload file', + }, +} as const; + +export default enUS; diff --git a/frontend/src/lib/i18n/index.test.ts b/frontend/src/lib/i18n/index.test.ts new file mode 100644 index 00000000..fc231b5d --- /dev/null +++ b/frontend/src/lib/i18n/index.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { getInitialLanguage, LANGUAGE_STORAGE_KEY, normalizeLanguage } from './index'; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('i18n language selection', () => { + it('normalizes Chinese browser variants to Simplified Chinese', () => { + expect(normalizeLanguage('zh')).toBe('zh-CN'); + expect(normalizeLanguage('zh-Hans-CN')).toBe('zh-CN'); + }); + + it('falls back to English for unsupported languages', () => { + expect(normalizeLanguage('de-DE')).toBe('en-US'); + }); + + it('prefers a stored language over browser detection', () => { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, 'zh-CN'); + + expect(getInitialLanguage()).toBe('zh-CN'); + }); +}); diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts new file mode 100644 index 00000000..ac9f258a --- /dev/null +++ b/frontend/src/lib/i18n/index.ts @@ -0,0 +1,64 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import enUS from './en-US'; +import zhCN from './zh-CN'; + +export const DEFAULT_LANGUAGE = 'en-US'; +export const LANGUAGE_STORAGE_KEY = 'pentagi.language'; +export const SUPPORTED_LANGUAGES = ['en-US', 'zh-CN'] as const; + +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +export const normalizeLanguage = (language?: null | string): SupportedLanguage => + language?.toLowerCase().startsWith('zh') ? 'zh-CN' : DEFAULT_LANGUAGE; + +export const getInitialLanguage = (): SupportedLanguage => { + try { + const storedLanguage = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + + if (storedLanguage) { + return normalizeLanguage(storedLanguage); + } + } catch { + // Storage can be disabled by the browser. Fall back to its UI language. + } + + return normalizeLanguage(typeof navigator === 'undefined' ? undefined : navigator.language); +}; + +const applyDocumentLanguage = (language: string) => { + if (typeof document !== 'undefined') { + document.documentElement.lang = normalizeLanguage(language); + } +}; + +void i18n.use(initReactI18next).init({ + fallbackLng: DEFAULT_LANGUAGE, + initImmediate: false, + interpolation: { + escapeValue: false, + }, + lng: getInitialLanguage(), + resources: { + 'en-US': { translation: enUS }, + 'zh-CN': { translation: zhCN }, + }, + supportedLngs: [...SUPPORTED_LANGUAGES], +}); + +applyDocumentLanguage(i18n.resolvedLanguage || i18n.language); + +i18n.on('languageChanged', (language) => { + const normalizedLanguage = normalizeLanguage(language); + + applyDocumentLanguage(normalizedLanguage); + + try { + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, normalizedLanguage); + } catch { + // The language still changes for the current session when storage is unavailable. + } +}); + +export default i18n; diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts new file mode 100644 index 00000000..9b4250ab --- /dev/null +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -0,0 +1,53 @@ +const zhCN = { + dashboard: { + analytics: '分析', + breadcrumb: '仪表盘', + month: '月', + overview: '概览', + quarter: '季度', + week: '周', + }, + language: { + action: 'English', + label: '语言', + switchAriaLabel: '切换语言为英文', + }, + login: { + authenticationFailed: '身份验证失败', + continueWithGithub: '使用 GitHub 继续', + continueWithGoogle: '使用 Google 继续', + emailPlaceholder: '输入电子邮箱', + invalidLogin: '登录名无效', + invalidLoginOrPassword: '登录名或密码错误', + loginLabel: '登录名', + loginRequired: '请输入登录名', + or: '或', + passwordLabel: '密码', + passwordPlaceholder: '输入密码', + passwordRequired: '请输入密码', + passwordUpdateDescription: '继续使用前需要修改密码。', + passwordUpdateTitle: '更新密码', + signIn: '登录', + }, + navigation: { + changePassword: '修改密码', + dashboard: '仪表盘', + favoriteFlows: '收藏的流程', + flows: '流程', + knowledges: '知识库', + logOut: '退出登录', + newFlow: '新建流程', + recentFlows: '最近流程', + resources: '资源', + settings: '设置', + templates: '模板', + theme: '主题', + themeDark: '深色主题', + themeLight: '浅色主题', + themeSystem: '跟随系统', + toggleFavorite: '切换收藏状态', + uploadFile: '上传文件', + }, +} as const; + +export default zhCN; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e1ca04f4..b0c219d6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import ReactDOM from 'react-dom/client'; import App from '@/app'; +import '@/lib/i18n'; ReactDOM.createRoot(document.querySelector('#root')!).render( diff --git a/frontend/src/pages/dashboard/dashboard.tsx b/frontend/src/pages/dashboard/dashboard.tsx index 92d1cf41..5b7af5cc 100644 --- a/frontend/src/pages/dashboard/dashboard.tsx +++ b/frontend/src/pages/dashboard/dashboard.tsx @@ -1,5 +1,6 @@ import { LayoutDashboard } from 'lucide-react'; import { useState, useTransition } from 'react'; +import { useTranslation } from 'react-i18next'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Separator } from '@/components/ui/separator'; @@ -11,10 +12,10 @@ import { cn } from '@/lib/utils'; import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics'; import { DashboardOverview } from '@/pages/dashboard/dashboard-overview'; -const periodOptions: { label: string; value: UsageStatsPeriod }[] = [ - { label: 'Week', value: UsageStatsPeriod.Week }, - { label: 'Month', value: UsageStatsPeriod.Month }, - { label: 'Quarter', value: UsageStatsPeriod.Quarter }, +const periodOptions: { labelKey: string; value: UsageStatsPeriod }[] = [ + { labelKey: 'dashboard.week', value: UsageStatsPeriod.Week }, + { labelKey: 'dashboard.month', value: UsageStatsPeriod.Month }, + { labelKey: 'dashboard.quarter', value: UsageStatsPeriod.Quarter }, ]; const VALID_PERIODS = new Set(Object.values(UsageStatsPeriod)); @@ -42,6 +43,7 @@ const savePeriod = (storageKey: string, value: UsageStatsPeriod): void => { }; function Dashboard() { + const { t } = useTranslation(); const { period: periodStorageKey } = usePageStorageKeys(); const [activeTab, setActiveTab] = useState('analytics'); const [period, setPeriod] = useState(() => loadPeriod(periodStorageKey)); @@ -85,7 +87,7 @@ function Dashboard() { - Dashboard + {t('dashboard.breadcrumb')} @@ -100,8 +102,8 @@ function Dashboard() { >
- Analytics - Overview + {t('dashboard.analytics')} + {t('dashboard.overview')} {activeTab === 'analytics' && ( @@ -110,22 +112,26 @@ function Dashboard() { value={period} > - {periodOptions.map(({ label, value }) => ( - - - {label} - - ))} + + {label} + + ); + })} )} diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 3a16cc24..3618531c 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -2,6 +2,7 @@ import { Loader2 } from 'lucide-react'; import { useLocation, useSearchParams } from 'react-router-dom'; import Logo from '@/components/icons/logo'; +import { LanguageToggle } from '@/components/shared/language-toggle'; import LoginForm from '@/features/authentication/login-form'; import { getSafeReturnUrl } from '@/lib/utils/auth'; import { useUser } from '@/providers/user-provider'; @@ -15,7 +16,8 @@ function Login() { const returnUrl = getSafeReturnUrl((location.state?.from as string) || searchParams.get('returnUrl'), '/flows/new'); return ( -
+
+
{!isLoading ? ( From ab08e994447c65c13f07e57af47724feb54c44bf Mon Sep 17 00:00:00 2001 From: 2331221 <98810877+2331221@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:10:02 +0800 Subject: [PATCH 2/2] feat(frontend): complete zh-CN interface coverage --- frontend/src/app.tsx | 8 +- .../src/components/dashboard/chart-card.tsx | 3 +- .../components/layouts/settings-layout.tsx | 21 +- .../components/shared/confirmation-dialog.tsx | 23 +- .../detail-navigation-buttons.tsx | 9 +- .../detail-navigation-sheet.tsx | 7 +- .../src/components/shared/document-title.tsx | 2 +- .../file-manager/file-manager-actions.tsx | 16 +- .../file-manager-bulk-actions-bar.tsx | 5 +- .../shared/file-manager/file-manager-row.tsx | 3 +- .../shared/file-manager/file-manager.tsx | 20 +- .../shared/inline-edit/inline-edit-input.tsx | 5 +- .../src/components/shared/markdown-editor.tsx | 57 +- .../shared/overwrite/overwrite-dialog.tsx | 7 +- .../src/components/shared/page-loader.tsx | 4 +- .../unsaved-changes-dialog.tsx | 7 +- frontend/src/components/ui/autocomplete.tsx | 2 +- frontend/src/components/ui/breadcrumb.tsx | 3 +- frontend/src/components/ui/data-table.tsx | 38 +- frontend/src/components/ui/dialog.tsx | 3 +- frontend/src/components/ui/file-drop-zone.tsx | 7 +- frontend/src/components/ui/input-search.tsx | 5 +- frontend/src/components/ui/sheet.tsx | 3 +- frontend/src/components/ui/sidebar.tsx | 7 +- frontend/src/components/ui/spinner.tsx | 9 +- .../authentication/password-change-form.tsx | 40 +- .../src/features/flows/agents/flow-agent.tsx | 5 +- .../src/features/flows/agents/flow-agents.tsx | 15 +- .../dashboard/flow-dashboard-overview.tsx | 87 ++- .../flows/dashboard/flow-dashboard.tsx | 5 +- .../flow-files-attach-resources-dialog.tsx | 29 +- .../flows/files/flow-files-constants.ts | 8 +- .../flows/files/flow-files-promote-dialog.tsx | 28 +- .../flows/files/flow-files-pull-dialog.tsx | 27 +- .../src/features/flows/files/flow-files.tsx | 37 +- .../flows/files/use-flow-container-files.ts | 3 +- .../files/use-flow-files-attach-resources.ts | 9 +- .../flows/files/use-flow-files-data.ts | 3 +- .../flows/files/use-flow-files-delete.ts | 7 +- .../flows/files/use-flow-files-promote.ts | 11 +- .../flows/files/use-flow-files-pull.ts | 7 +- .../flows/files/use-flow-files-upload.ts | 9 +- .../src/features/flows/flow-central-tabs.tsx | 7 +- frontend/src/features/flows/flow-form.tsx | 43 +- frontend/src/features/flows/flow-tabs.tsx | 21 +- .../features/flows/flow-tasks-dropdown.tsx | 9 +- .../messages/flow-assistant-messages.tsx | 49 +- .../messages/flow-automation-messages.tsx | 28 +- .../features/flows/messages/flow-message.tsx | 7 +- .../flows/screenshots/flow-screenshot.tsx | 3 +- .../flows/screenshots/flow-screenshots.tsx | 9 +- .../src/features/flows/tasks/flow-subtask.tsx | 5 +- .../src/features/flows/tasks/flow-task.tsx | 7 +- .../src/features/flows/tasks/flow-tasks.tsx | 9 +- .../features/flows/terminal/flow-terminal.tsx | 15 +- .../src/features/flows/tools/flow-tool.tsx | 7 +- .../src/features/flows/tools/flow-tools.tsx | 13 +- .../flows/vector-stores/flow-vector-store.tsx | 42 +- .../vector-stores/flow-vector-stores.tsx | 13 +- .../knowledges/knowledge-form-controls.tsx | 29 +- .../knowledges/knowledge-form-layout.tsx | 7 +- .../features/knowledges/knowledge-form.tsx | 21 +- .../features/knowledges/knowledge-header.tsx | 35 +- .../resources/resources-copy-dialog.tsx | 27 +- .../resources/resources-mkdir-dialog.tsx | 7 +- .../resources/resources-move-dialog.tsx | 21 +- .../features/resources/use-resources-copy.ts | 11 +- .../resources/use-resources-delete.ts | 5 +- .../features/resources/use-resources-mkdir.ts | 7 +- .../features/resources/use-resources-move.ts | 11 +- .../resources/use-resources-upload.ts | 9 +- frontend/src/lib/axios.ts | 3 +- frontend/src/lib/clipboard.ts | 9 +- frontend/src/lib/i18n/index.test.ts | 31 +- frontend/src/lib/i18n/index.ts | 10 +- frontend/src/lib/i18n/ui.ts | 680 ++++++++++++++++++ frontend/src/lib/report/report.ts | 3 +- .../src/lib/route-titles/format-prompt-id.ts | 4 +- frontend/src/lib/route-titles/index.ts | 29 +- frontend/src/lib/upload-validation.ts | 2 +- .../pages/dashboard/dashboard-analytics.tsx | 33 +- .../pages/dashboard/dashboard-overview.tsx | 61 +- frontend/src/pages/flows/flow-report.tsx | 19 +- frontend/src/pages/flows/flow.tsx | 51 +- frontend/src/pages/flows/flows.tsx | 85 +-- frontend/src/pages/flows/new-flow.tsx | 19 +- frontend/src/pages/knowledges/knowledge.tsx | 3 +- frontend/src/pages/knowledges/knowledges.tsx | 59 +- frontend/src/pages/oauth-result.tsx | 5 +- frontend/src/pages/resources/resources.tsx | 53 +- .../pages/settings/settings-api-tokens.tsx | 89 +-- .../src/pages/settings/settings-prompt.tsx | 98 +-- .../src/pages/settings/settings-prompts.tsx | 101 +-- .../src/pages/settings/settings-provider.tsx | 135 ++-- .../src/pages/settings/settings-providers.tsx | 53 +- frontend/src/pages/templates/template.tsx | 74 +- frontend/src/pages/templates/templates.tsx | 35 +- frontend/src/providers/favorites-provider.tsx | 9 +- frontend/src/providers/flow-provider.tsx | 27 +- frontend/src/providers/flows-provider.tsx | 30 +- .../src/providers/knowledges-provider.tsx | 16 +- frontend/src/providers/resources-provider.tsx | 5 +- frontend/src/providers/templates-provider.tsx | 13 +- frontend/src/providers/user-provider.tsx | 13 +- 104 files changed, 1939 insertions(+), 999 deletions(-) create mode 100644 frontend/src/lib/i18n/ui.ts diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 7e3e9d93..550e66d2 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -1,5 +1,6 @@ import { ApolloProvider } from '@apollo/client/react'; import { lazy, Suspense } from 'react'; +import { useTranslation } from 'react-i18next'; import { createBrowserRouter, createRoutesFromElements, @@ -273,11 +274,16 @@ const router = createBrowserRouter( ); function App() { + const { i18n } = useTranslation(); + return ( - + ); diff --git a/frontend/src/components/dashboard/chart-card.tsx b/frontend/src/components/dashboard/chart-card.tsx index 03e38304..811ec4ad 100644 --- a/frontend/src/components/dashboard/chart-card.tsx +++ b/frontend/src/components/dashboard/chart-card.tsx @@ -4,6 +4,7 @@ import { BarChart2, Loader2 } from 'lucide-react'; import { ResponsiveContainer } from 'recharts'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { uiT } from '@/lib/i18n'; export function ChartCard({ children, @@ -42,7 +43,7 @@ export function ChartCard({ style={{ height }} > -

No data for this period

+

{uiT('No data for this period')}

) : ( , id: 'providers', path: '/settings/providers', - title: 'Providers', + title: uiT('Providers'), }, { icon: , id: 'prompts', path: '/settings/prompts', - title: 'Prompts', + title: uiT('Prompts'), }, { icon: , id: 'api-tokens', path: '/settings/api-tokens', - title: 'API Tokens', + title: uiT('API Tokens'), }, ] as const; @@ -59,24 +60,24 @@ function SettingsHeader() { const path = location.pathname; if (path === '/settings/providers/new') { - return 'Create Provider'; + return uiT('Create Provider'); } if (path.startsWith('/settings/providers/') && params.providerId && params.providerId !== 'new') { - return 'Edit Provider'; + return uiT('Edit Provider'); } if (path === '/settings/prompts/new') { - return 'Create Prompt'; + return uiT('Create Prompt'); } if (path.startsWith('/settings/prompts/') && params.promptId && params.promptId !== 'new') { - return 'Edit Prompt'; + return uiT('Edit Prompt'); } const activeItem = menuItems.find((item) => path.startsWith(item.path)); - return activeItem?.title ?? 'Settings'; + return activeItem?.title ?? uiT('Settings'); }, [location.pathname, params]); return ( @@ -117,7 +118,7 @@ function SettingsSidebar() {
- Settings + {uiT('Settings')}
@@ -140,7 +141,7 @@ function SettingsSidebar() { - Back to App + {uiT('Back to App')} diff --git a/frontend/src/components/shared/confirmation-dialog.tsx b/frontend/src/components/shared/confirmation-dialog.tsx index 1ec2bacb..61982cf1 100644 --- a/frontend/src/components/shared/confirmation-dialog.tsx +++ b/frontend/src/components/shared/confirmation-dialog.tsx @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; type ConfirmationDialogIconProps = ReactElement>; @@ -35,10 +36,10 @@ interface ConfirmationDialogProps { function ConfirmationDialog({ cancelIcon, - cancelText = 'Cancel', + cancelText = uiT('Cancel'), cancelVariant = 'outline', confirmIcon = , - confirmText = 'Confirm', + confirmText = uiT('Confirm'), confirmVariant = 'destructive', description, handleConfirm, @@ -54,14 +55,16 @@ function ConfirmationDialog({ // see "Confirm Action" for a Delete prompt or a Save prompt. Explicit // `title` always wins. const verb = confirmText.trim(); - const resolvedTitle = title ?? (verb && verb !== 'Confirm' ? `${verb} ${itemType}` : 'Confirm Action'); - - const defaultDescription = description || ( - <> - Are you sure you want to {verb.toLowerCase() || 'perform this action on'}{' '} - {itemName} {itemType}? - - ); + const resolvedTitle = + title ?? (verb && verb !== uiT('Confirm') ? `${verb} ${uiT(itemType)}` : uiT('Confirm Action')); + + const defaultDescription = + description || + uiT('Are you sure you want to {{verb}} {{itemName}} {{itemType}}?', { + itemName, + itemType: uiT(itemType), + verb: verb.toLowerCase() || uiT('perform this action on'), + }); const processIcon = (icon?: ConfirmationDialogIconProps): ConfirmationDialogIconProps | null => { if (!icon) { diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx index d60451f4..7e187c28 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-buttons.tsx @@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import type { DetailNavigationController } from './use-detail-navigation'; @@ -42,7 +43,7 @@ export function DetailNavigationButtons({ - Previous + {uiT('Previous')} @@ -76,7 +77,7 @@ export function DetailNavigationButtons({ - Next + {uiT('Next')}
); diff --git a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx index fba58137..a7df7261 100644 --- a/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx +++ b/frontend/src/components/shared/detail-navigation/detail-navigation-sheet.tsx @@ -4,6 +4,7 @@ import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, us import { Badge } from '@/components/ui/badge'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import type { DetailNavigationController } from './use-detail-navigation'; @@ -372,7 +373,7 @@ export function DetailNavigationSheet({ {hasClearButton ? ( ({ ) : (
{trimmedQuery.length > 0 - ? `No items match "${trimmedQuery}".` - : 'No items match the current filter.'} + ? uiT('No items match "{{query}}".', { query: trimmedQuery }) + : uiT('No items match the current filter.')}
)} diff --git a/frontend/src/components/shared/document-title.tsx b/frontend/src/components/shared/document-title.tsx index 5b4cd327..90df2fa6 100644 --- a/frontend/src/components/shared/document-title.tsx +++ b/frontend/src/components/shared/document-title.tsx @@ -23,7 +23,7 @@ const hasTitle = (handle: unknown): handle is { title: TitleResolver } => { * Document `` driver. Walks `useMatches()` for the deepest route * exposing `handle.title` and renders accordingly: * - * handle: { title: 'Dashboard' } // static + * handle: { title: uiT('Dashboard') } // static * handle: { title: (p) => formatPromptId(p.promptId!) } // params-derived * handle: { title: FlowTitle } // reactive (Apollo) * diff --git a/frontend/src/components/shared/file-manager/file-manager-actions.tsx b/frontend/src/components/shared/file-manager/file-manager-actions.tsx index 04c1afba..62b6845a 100644 --- a/frontend/src/components/shared/file-manager/file-manager-actions.tsx +++ b/frontend/src/components/shared/file-manager/file-manager-actions.tsx @@ -1,5 +1,7 @@ import { ClipboardCopy, Copy, Download, FileSymlink, FolderOutput, Trash2 } from 'lucide-react'; +import { uiT } from '@/lib/i18n'; + import type { FileManagerAction, FileManagerBulkAction, FileNode } from './file-manager-types'; /** @@ -21,7 +23,7 @@ export const downloadAction = ( getHrefDownloadAttr: (file) => (file.isDir ? `${file.name}.${archiveExtension}` : file.name), icon: Download, id: '__builtin_download', - label: 'Download', + label: uiT('Download'), onSelect: () => {}, }; }; @@ -31,7 +33,7 @@ export const copyPathAction = (onCopyPath: (file: FileNode) => void): FileManage appliesToDirs: true, icon: ClipboardCopy, id: '__builtin_copy_path', - label: 'Copy path', + label: uiT('Copy path'), onSelect: onCopyPath, }); @@ -43,7 +45,7 @@ export const deleteAction = (onDelete: (file: FileNode) => void): FileManagerAct appliesToDirs: true, icon: Trash2, id: '__builtin_delete', - label: 'Delete', + label: uiT('Delete'), onSelect: onDelete, separatorBefore: true, }); @@ -76,7 +78,7 @@ export const bulkDeleteAction = ( onDelete: (files: FileNode[]) => Promise<void> | void, options: BulkDeleteOptions = {}, ): FileManagerBulkAction => { - const label = options.label ?? 'Delete'; + const label = options.label ?? uiT('Delete'); return { confirm: { @@ -105,7 +107,7 @@ export const bulkCopyPathsAction = ( ): FileManagerBulkAction => ({ icon: ClipboardCopy, id: '__builtin_bulk_copy_paths', - label: options.label ?? 'Copy paths', + label: options.label ?? uiT('Copy paths'), onSelect: (files) => onCopy(files.map((file) => file.path)), overflow: options.overflow ?? true, }); @@ -150,7 +152,7 @@ export const bulkPromoteAction = ( ): FileManagerBulkAction => ({ icon: FolderOutput, id: '__builtin_bulk_promote', - label: options.label ?? 'Save as resources', + label: options.label ?? uiT('Save as resources'), onSelect: onPromote, overflow: options.overflow, }); @@ -194,7 +196,7 @@ export const bulkDownloadAction = ( ): FileManagerBulkAction => ({ icon: Download, id: '__builtin_bulk_download', - label: options.label ?? 'Download', + label: options.label ?? uiT('Download'), onSelect: (files) => { if (files.length === 0) { return; diff --git a/frontend/src/components/shared/file-manager/file-manager-bulk-actions-bar.tsx b/frontend/src/components/shared/file-manager/file-manager-bulk-actions-bar.tsx index 212e17e7..554bd0c3 100644 --- a/frontend/src/components/shared/file-manager/file-manager-bulk-actions-bar.tsx +++ b/frontend/src/components/shared/file-manager/file-manager-bulk-actions-bar.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import type { FileManagerBulkAction, FileManagerLabels, FileNode } from './file-manager-types'; @@ -137,8 +138,8 @@ export function FileManagerBulkActionsBar({ const baseSelectedText = labels.selectedLabel?.(selectedPaths.size) ?? `${selectedPaths.size} selected`; const sizeSuffix = (labels.formatSelectionSize ?? formatFileSize)(selectionTotalBytes); const selectedText = sizeSuffix ? `${baseSelectedText} · ${sizeSuffix}` : baseSelectedText; - const cancelText = labels.bulkCancel ?? 'Cancel'; - const moreActionsText = labels.bulkMoreActions ?? 'More actions'; + const cancelText = labels.bulkCancel ?? uiT('Cancel'); + const moreActionsText = labels.bulkMoreActions ?? uiT('More actions'); return ( <> diff --git a/frontend/src/components/shared/file-manager/file-manager-row.tsx b/frontend/src/components/shared/file-manager/file-manager-row.tsx index 64f6041f..f8956157 100644 --- a/frontend/src/components/shared/file-manager/file-manager-row.tsx +++ b/frontend/src/components/shared/file-manager/file-manager-row.tsx @@ -27,6 +27,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import type { FileManagerAction, FileManagerInternalNode, FileNode } from './file-manager-types'; @@ -503,7 +504,7 @@ function FileManagerRowImpl({ <DropdownMenu onOpenChange={setIsDropdownMenuOpen}> <DropdownMenuTrigger asChild> <Button - aria-label="Row actions" + aria-label={uiT('Row actions')} className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100" size="icon-xs" variant="ghost" diff --git a/frontend/src/components/shared/file-manager/file-manager.tsx b/frontend/src/components/shared/file-manager/file-manager.tsx index f7b217e2..6b63b5aa 100644 --- a/frontend/src/components/shared/file-manager/file-manager.tsx +++ b/frontend/src/components/shared/file-manager/file-manager.tsx @@ -19,6 +19,7 @@ import { ContextMenuTrigger, } from '@/components/ui/context-menu'; import { useLatestRef } from '@/hooks/use-latest-ref'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import type { FileManagerRowDisplay, FileManagerRowHandlers } from './file-manager-row'; @@ -460,7 +461,7 @@ export function FileManager({ // clicks outside any row, which is the entire point. const treeBody = ( <div - aria-label="File tree" + aria-label={uiT('File tree')} aria-multiselectable={isCheckboxVisible || undefined} className={cn( 'flex flex-1 flex-col overflow-y-auto py-1 transition-colors', @@ -520,7 +521,7 @@ export function FileManager({ > {isCheckboxVisible ? ( <Checkbox - aria-label={effectiveLabels.selectAllAriaLabel ?? 'Select all'} + aria-label={effectiveLabels.selectAllAriaLabel ?? uiT('Select all')} checked={getCheckboxState(isAllSelected, isSomeSelected)} onCheckedChange={toggleSelectAll} /> @@ -536,8 +537,8 @@ export function FileManager({ aria-expanded={isAllExpanded} aria-label={ isAllExpanded - ? (effectiveLabels.collapseAllAriaLabel ?? 'Collapse all') - : (effectiveLabels.expandAllAriaLabel ?? 'Expand all') + ? (effectiveLabels.collapseAllAriaLabel ?? uiT('Collapse all')) + : (effectiveLabels.expandAllAriaLabel ?? uiT('Expand all')) } className="text-muted-foreground hover:bg-muted hover:text-primary focus-visible:ring-ring -mx-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors outline-none focus-visible:ring-1" onClick={toggleExpandAll} @@ -553,11 +554,16 @@ export function FileManager({ className="-mx-0.5 size-4 shrink-0" /> )} - {renderSortableHeader('name', effectiveLabels.columnName ?? 'Name', isNameSortable)} + {renderSortableHeader('name', effectiveLabels.columnName ?? uiT('Name'), isNameSortable)} </div> - {isSizeVisible && renderSortableHeader('size', effectiveLabels.columnSize ?? 'Size', isSizeSortable)} + {isSizeVisible && + renderSortableHeader('size', effectiveLabels.columnSize ?? uiT('Size'), isSizeSortable)} {isModifiedVisible && - renderSortableHeader('modified', effectiveLabels.columnModified ?? 'Modified', isModifiedSortable)} + renderSortableHeader( + 'modified', + effectiveLabels.columnModified ?? uiT('Modified'), + isModifiedSortable, + )} {hasActions && ( <span aria-hidden="true" diff --git a/frontend/src/components/shared/inline-edit/inline-edit-input.tsx b/frontend/src/components/shared/inline-edit/inline-edit-input.tsx index 656efbad..bd975fdc 100644 --- a/frontend/src/components/shared/inline-edit/inline-edit-input.tsx +++ b/frontend/src/components/shared/inline-edit/inline-edit-input.tsx @@ -2,6 +2,7 @@ import { Check, Loader2, X } from 'lucide-react'; import { type KeyboardEvent, type Ref } from 'react'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; interface InlineEditInputProps { @@ -94,14 +95,14 @@ export function InlineEditInput({ className="gap-0 pr-2" > <InputGroupButton - aria-label="Save" + aria-label={uiT('Save')} disabled={busy} onClick={onSave} > {busy ? <Loader2 className="animate-spin" /> : <Check />} </InputGroupButton> <InputGroupButton - aria-label="Cancel" + aria-label={uiT('Cancel')} disabled={busy} onClick={onCancel} > diff --git a/frontend/src/components/shared/markdown-editor.tsx b/frontend/src/components/shared/markdown-editor.tsx index cc6f9450..c451c3b4 100644 --- a/frontend/src/components/shared/markdown-editor.tsx +++ b/frontend/src/components/shared/markdown-editor.tsx @@ -27,6 +27,7 @@ import { Markdown } from 'tiptap-markdown'; import { Separator } from '@/components/ui/separator'; import { Toggle } from '@/components/ui/toggle'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; export interface MarkdownEditorHandle { @@ -330,20 +331,20 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) data-slot="markdown-editor-toolbar" > <Toggle - aria-label="Bold" + aria-label={uiT('Bold')} onPressedChange={() => editor.chain().focus().toggleBold().run()} pressed={editor.isActive('bold')} size="sm" - title="Bold (Ctrl+B)" + title={uiT('Bold (Ctrl+B)')} > <Bold /> </Toggle> <Toggle - aria-label="Italic" + aria-label={uiT('Italic')} onPressedChange={() => editor.chain().focus().toggleItalic().run()} pressed={editor.isActive('italic')} size="sm" - title="Italic (Ctrl+I)" + title={uiT('Italic (Ctrl+I)')} > <Italic /> </Toggle> @@ -357,11 +358,11 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) <Strikethrough /> </Toggle> <Toggle - aria-label="Inline code" + aria-label={uiT('Inline code')} onPressedChange={() => editor.chain().focus().toggleCode().run()} pressed={editor.isActive('code')} size="sm" - title="Inline code" + title={uiT('Inline code')} > <Code /> </Toggle> @@ -372,29 +373,29 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) /> <Toggle - aria-label="Heading 1" + aria-label={uiT('Heading 1')} onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} pressed={editor.isActive('heading', { level: 1 })} size="sm" - title="Heading 1" + title={uiT('Heading 1')} > <Heading1 /> </Toggle> <Toggle - aria-label="Heading 2" + aria-label={uiT('Heading 2')} onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} pressed={editor.isActive('heading', { level: 2 })} size="sm" - title="Heading 2" + title={uiT('Heading 2')} > <Heading2 /> </Toggle> <Toggle - aria-label="Heading 3" + aria-label={uiT('Heading 3')} onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} pressed={editor.isActive('heading', { level: 3 })} size="sm" - title="Heading 3" + title={uiT('Heading 3')} > <Heading3 /> </Toggle> @@ -405,20 +406,20 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) /> <Toggle - aria-label="Bullet list" + aria-label={uiT('Bullet list')} onPressedChange={() => editor.chain().focus().toggleBulletList().run()} pressed={editor.isActive('bulletList')} size="sm" - title="Bullet list" + title={uiT('Bullet list')} > <List /> </Toggle> <Toggle - aria-label="Ordered list" + aria-label={uiT('Ordered list')} onPressedChange={() => editor.chain().focus().toggleOrderedList().run()} pressed={editor.isActive('orderedList')} size="sm" - title="Ordered list" + title={uiT('Ordered list')} > <ListOrdered /> </Toggle> @@ -429,60 +430,60 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) /> <Toggle - aria-label="Blockquote" + aria-label={uiT('Blockquote')} onPressedChange={() => editor.chain().focus().toggleBlockquote().run()} pressed={editor.isActive('blockquote')} size="sm" - title="Blockquote" + title={uiT('Blockquote')} > <Quote /> </Toggle> <Toggle - aria-label="Code block" + aria-label={uiT('Code block')} onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()} pressed={editor.isActive('codeBlock')} size="sm" - title="Code block" + title={uiT('Code block')} > <Code2 /> </Toggle> <Toggle - aria-label="Link" + aria-label={uiT('Link')} onPressedChange={handleSetLink} pressed={editor.isActive('link')} size="sm" - title="Insert link" + title={uiT('Insert link')} > <LinkIcon /> </Toggle> <Toggle - aria-label="Horizontal rule" + aria-label={uiT('Horizontal rule')} onPressedChange={() => editor.chain().focus().setHorizontalRule().run()} pressed={false} size="sm" - title="Horizontal rule" + title={uiT('Horizontal rule')} > <Minus /> </Toggle> <div className="ml-auto flex items-center gap-0.5"> <Toggle - aria-label="Undo" + aria-label={uiT('Undo')} disabled={!editor.can().undo()} onPressedChange={() => editor.chain().focus().undo().run()} pressed={false} size="sm" - title="Undo (Ctrl+Z)" + title={uiT('Undo (Ctrl+Z)')} > <Undo /> </Toggle> <Toggle - aria-label="Redo" + aria-label={uiT('Redo')} disabled={!editor.can().redo()} onPressedChange={() => editor.chain().focus().redo().run()} pressed={false} size="sm" - title="Redo (Ctrl+Shift+Z)" + title={uiT('Redo (Ctrl+Shift+Z)')} > <Redo /> </Toggle> diff --git a/frontend/src/components/shared/overwrite/overwrite-dialog.tsx b/frontend/src/components/shared/overwrite/overwrite-dialog.tsx index ab91f4e6..cb52d5f4 100644 --- a/frontend/src/components/shared/overwrite/overwrite-dialog.tsx +++ b/frontend/src/components/shared/overwrite/overwrite-dialog.tsx @@ -1,6 +1,7 @@ import { Replace } from 'lucide-react'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; +import { uiT } from '@/lib/i18n'; export interface OverwriteConflict { destination: string; @@ -46,7 +47,7 @@ const buildDefaultDescription = (conflicts: OverwriteConflict[]): string | undef return undefined; }; -const buildDefaultConfirmText = (count: number): string => (count > 1 ? 'Replace all' : 'Replace'); +const buildDefaultConfirmText = (count: number): string => (count > 1 ? uiT('Replace all') : uiT('Replace')); /** * Shared "Replace or cancel" confirmation for destructive overwrite flows @@ -63,11 +64,11 @@ export function OverwriteDialog({ description, onCancel, onReplaceAll, - title = 'Replace existing item?', + title = uiT('Replace existing item?'), }: OverwriteDialogProps) { return ( <ConfirmationDialog - cancelText="Cancel" + cancelText={uiT('Cancel')} confirmIcon={<Replace />} confirmText={confirmText ?? buildDefaultConfirmText(conflicts.length)} confirmVariant="destructive" diff --git a/frontend/src/components/shared/page-loader.tsx b/frontend/src/components/shared/page-loader.tsx index a8f4345b..814c1a70 100644 --- a/frontend/src/components/shared/page-loader.tsx +++ b/frontend/src/components/shared/page-loader.tsx @@ -1,7 +1,9 @@ +import { uiT } from '@/lib/i18n'; + function PageLoader() { return ( <div className="grid h-screen w-full place-items-center"> - <p>Loading...</p> + <p>{uiT('Loading...')}</p> </div> ); } diff --git a/frontend/src/components/shared/unsaved-changes/unsaved-changes-dialog.tsx b/frontend/src/components/shared/unsaved-changes/unsaved-changes-dialog.tsx index 472d0b31..391438a4 100644 --- a/frontend/src/components/shared/unsaved-changes/unsaved-changes-dialog.tsx +++ b/frontend/src/components/shared/unsaved-changes/unsaved-changes-dialog.tsx @@ -12,6 +12,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { Spinner } from '@/components/ui/spinner'; +import { uiT } from '@/lib/i18n'; export interface UnsavedChangesDialogProps { /** When `false`, the "Save & leave" button is disabled (e.g. form is invalid). */ @@ -33,7 +34,7 @@ export interface UnsavedChangesDialogProps { function UnsavedChangesDialog({ canSave, description = 'You have unsaved changes on this page. Would you like to save them before leaving?', - discardText = 'Discard', + discardText = uiT('Discard'), handleCancel, handleDiscard, handleOpenChange, @@ -41,7 +42,7 @@ function UnsavedChangesDialog({ isOpen, isSavingFromDialog, saveIcon = <Save />, - saveText = 'Save', + saveText = uiT('Save'), title = 'Unsaved changes', }: UnsavedChangesDialogProps) { return ( @@ -72,7 +73,7 @@ function UnsavedChangesDialog({ onClick={handleCancel} variant="outline" > - Cancel + {uiT('Cancel')} </Button> <Button disabled={isSavingFromDialog} diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx index ce82af89..03fd8fc6 100644 --- a/frontend/src/components/ui/autocomplete.tsx +++ b/frontend/src/components/ui/autocomplete.tsx @@ -42,7 +42,7 @@ interface AutocompleteContextValue { * <Autocomplete value={path} onValueChange={setPath} onCommit={navigateTo}> * <AutocompleteInput placeholder="/work" /> * <AutocompleteContent> - * <AutocompleteEmpty>No matches</AutocompleteEmpty> + * <AutocompleteEmpty>{uiT('No matches')}</AutocompleteEmpty> * <AutocompleteGroup heading="Known paths"> * {paths.map((p) => ( * <AutocompleteItem key={p} value={p}>{p}</AutocompleteItem> diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx index 97c5f69c..8ad20c8d 100644 --- a/frontend/src/components/ui/breadcrumb.tsx +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -2,6 +2,7 @@ import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'; import { Slot } from '@radix-ui/react-slot'; import * as React from 'react'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; function Breadcrumb({ @@ -26,7 +27,7 @@ function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<'span' {...props} > <DotsHorizontalIcon className="h-4 w-4" /> - <span className="sr-only">More</span> + <span className="sr-only">{uiT('More')}</span> </span> ); } diff --git a/frontend/src/components/ui/data-table.tsx b/frontend/src/components/ui/data-table.tsx index e94a2964..e94e55fb 100644 --- a/frontend/src/components/ui/data-table.tsx +++ b/frontend/src/components/ui/data-table.tsx @@ -58,6 +58,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { useEffectAfterMount } from '@/hooks/use-effect-after-mount'; import { useLatestRef } from '@/hooks/use-latest-ref'; import { usePageStorageKeys } from '@/hooks/use-page-storage-keys'; +import { uiT } from '@/lib/i18n'; import { migrateLegacyTableState, updateTableState } from '@/lib/table-state'; import { cn } from '@/lib/utils'; @@ -167,7 +168,7 @@ interface DataTableFilterProps { function DataTableEmptyState({ entityName, filterValue }: DataTableEmptyStateProps) { if (!entityName) { - return <>No results.</>; + return <>{uiT('No results.')}</>; } const hasFilter = filterValue.length > 0; @@ -179,10 +180,15 @@ function DataTableEmptyState({ entityName, filterValue }: DataTableEmptyStatePro <EmptyMedia variant="icon"> <Icon /> </EmptyMedia> - <EmptyTitle>{hasFilter ? 'No matches' : `No ${entityName} yet`}</EmptyTitle> + <EmptyTitle> + {hasFilter ? uiT('No matches') : uiT('No {{entity}} yet', { entity: uiT(entityName) })} + </EmptyTitle> {hasFilter ? ( <EmptyDescription> - No {entityName} match <code>{filterValue}</code>. Try a different query. + {uiT('No {{entity}} match {{query}}. Try a different query.', { + entity: uiT(entityName), + query: filterValue, + })} </EmptyDescription> ) : null} </EmptyHeader> @@ -627,7 +633,7 @@ function DataTable<TData, TValue = unknown>({ <DropdownMenu> <DropdownMenuTrigger asChild> <Button - aria-label="Search in" + aria-label={uiT('Search in')} className="shrink-0" size="icon" variant="outline" @@ -677,7 +683,7 @@ function DataTable<TData, TValue = unknown>({ <DropdownMenu> <DropdownMenuTrigger asChild> <Button - aria-label="Columns" + aria-label={uiT('Columns')} className="ml-auto shrink-0" size="icon" variant="outline" @@ -814,16 +820,20 @@ function DataTable<TData, TValue = unknown>({ <div className="text-muted-foreground flex-1 text-xs text-nowrap"> {totalRows > 0 ? ( <> - Showing {rangeStart}–{rangeEnd} of {totalRows} + {uiT('Showing {{start}}–{{end}} of {{total}}', { + end: rangeEnd, + start: rangeStart, + total: totalRows, + })} </> ) : empty?.entityName ? ( - `No ${empty.entityName}` + uiT('No {{entity}}', { entity: uiT(empty.entityName) }) ) : ( - 'No results' + uiT('No results') )} </div> <div className="flex items-center gap-2"> - <span className="text-xs font-medium">Rows per page</span> + <span className="text-xs font-medium">{uiT('Rows per page')}</span> <Select onValueChange={(value) => { const pageSize = value === 'all' ? data.length : Number.parseInt(value, 10); @@ -846,7 +856,7 @@ function DataTable<TData, TValue = unknown>({ {size} </SelectItem> ))} - <SelectItem value="all">All</SelectItem> + <SelectItem value="all">{uiT('All')}</SelectItem> </SelectContent> </Select> </div> @@ -862,7 +872,7 @@ function DataTable<TData, TValue = unknown>({ )} <div className="flex items-center gap-1"> <Button - aria-label="First page" + aria-label={uiT('First page')} disabled={!table.getCanPreviousPage()} onClick={() => table.firstPage()} size="icon-xs" @@ -871,7 +881,7 @@ function DataTable<TData, TValue = unknown>({ <ChevronsLeft /> </Button> <Button - aria-label="Previous page" + aria-label={uiT('Previous page')} disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()} size="icon-xs" @@ -880,7 +890,7 @@ function DataTable<TData, TValue = unknown>({ <ChevronLeft /> </Button> <Button - aria-label="Next page" + aria-label={uiT('Next page')} disabled={!table.getCanNextPage()} onClick={() => table.nextPage()} size="icon-xs" @@ -889,7 +899,7 @@ function DataTable<TData, TValue = unknown>({ <ChevronRight /> </Button> <Button - aria-label="Last page" + aria-label={uiT('Last page')} disabled={!table.getCanNextPage()} onClick={() => table.lastPage()} size="icon-xs" diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 9164acb9..37ab91f0 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -2,6 +2,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { X } from 'lucide-react'; import * as React from 'react'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; const Dialog = DialogPrimitive.Root; @@ -26,7 +27,7 @@ function DialogContent({ children, className, ...props }: React.ComponentProps<t {children} <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <X className="h-4 w-4" /> - <span className="sr-only">Close</span> + <span className="sr-only">{uiT('Close')}</span> </DialogPrimitive.Close> </DialogPrimitive.Content> </DialogPortal> diff --git a/frontend/src/components/ui/file-drop-zone.tsx b/frontend/src/components/ui/file-drop-zone.tsx index 27e48535..b8c246b7 100644 --- a/frontend/src/components/ui/file-drop-zone.tsx +++ b/frontend/src/components/ui/file-drop-zone.tsx @@ -1,6 +1,7 @@ import { UploadCloudIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; export interface FileDropZoneProps { @@ -30,14 +31,14 @@ export interface FileDropZoneProps { * `useResourcesUpload().openFilePicker`). */ export function FileDropZone({ - actionLabel = 'Browse files', + actionLabel = uiT('Browse files'), className, description = 'or click the button to choose files from your computer', hint, isDragging = false, isUploading = false, onBrowse, - title = 'Drag and drop your files', + title = uiT('Drag and drop your files'), }: FileDropZoneProps) { return ( <div @@ -55,7 +56,7 @@ export function FileDropZone({ isDragging && 'text-foreground scale-110', )} /> - <span className="text-sm font-medium">{isDragging ? 'Drop files here' : title}</span> + <span className="text-sm font-medium">{isDragging ? uiT('Drop files here') : title}</span> <span className="text-muted-foreground mt-1 max-w-md text-xs">{description}</span> <Button className="mt-4 h-7 text-xs" diff --git a/frontend/src/components/ui/input-search.tsx b/frontend/src/components/ui/input-search.tsx index 8349e54d..cc6c61cb 100644 --- a/frontend/src/components/ui/input-search.tsx +++ b/frontend/src/components/ui/input-search.tsx @@ -6,6 +6,7 @@ import { useDebouncedCallback } from 'use-debounce'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'; import { Kbd, KbdGroup } from '@/components/ui/kbd'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { isMac } from '@/lib/utils/platform'; @@ -33,12 +34,12 @@ interface InputSearchProps { * through `handleClear`. A second `Escape` on an empty field collapses + blurs. */ export function InputSearch({ - ariaLabel = 'Search', + ariaLabel = uiT('Search'), className, hotkey = 'f', maxWidth = 140, onSearchChange, - placeholder = 'Search...', + placeholder = uiT('Search...'), searchQuery, }: InputSearchProps) { const [isExpanded, setIsExpanded] = useState(() => searchQuery.trim().length > 0); diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 93882abf..ca59cea8 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -5,6 +5,7 @@ import { Cross2Icon } from '@radix-ui/react-icons'; import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; const Sheet = SheetPrimitive.Root; @@ -60,7 +61,7 @@ function SheetContent({ children, className, container, overlay = true, side = ' > <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <Cross2Icon className="h-4 w-4" /> - <span className="sr-only">Close</span> + <span className="sr-only">{uiT('Close')}</span> </SheetPrimitive.Close> {children} </SheetPrimitive.Content> diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index 4feefbbb..d43f2d9e 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -12,6 +12,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useBreakpoint } from '@/hooks/use-breakpoint'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; const SIDEBAR_COOKIE_NAME = 'sidebar:state'; @@ -422,7 +423,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { return ( <button - aria-label="Toggle Sidebar" + aria-label={uiT('Toggle Sidebar')} className={cn( 'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex', 'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize', @@ -435,7 +436,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) { data-sidebar="rail" onClick={toggleSidebar} tabIndex={-1} - title="Toggle Sidebar" + title={uiT('Toggle Sidebar')} {...props} /> ); @@ -467,7 +468,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t {...props} > <PanelLeft /> - <span className="sr-only">Toggle Sidebar</span> + <span className="sr-only">{uiT('Toggle Sidebar')}</span> </Button> ); } diff --git a/frontend/src/components/ui/spinner.tsx b/frontend/src/components/ui/spinner.tsx index 1f99cf0d..d4b7b119 100644 --- a/frontend/src/components/ui/spinner.tsx +++ b/frontend/src/components/ui/spinner.tsx @@ -1,5 +1,6 @@ import { LoaderCircleIcon, LoaderIcon, LoaderPinwheelIcon, type LucideProps } from 'lucide-react'; +import { uiT } from '@/lib/i18n'; import { cn } from '@/lib/utils'; export type SpinnerProps = LucideProps & { @@ -38,7 +39,7 @@ function Bars({ size = 24, ...props }: SpinnerVariantProps) { xmlns="http://www.w3.org/2000/svg" {...props} > - <title>Loading... + {uiT('Loading...')}