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/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')}
) : (
(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/layouts/settings-layout.tsx b/frontend/src/components/layouts/settings-layout.tsx
index 64f3deb1..d172fd8a 100644
--- a/frontend/src/components/layouts/settings-layout.tsx
+++ b/frontend/src/components/layouts/settings-layout.tsx
@@ -17,6 +17,7 @@ import {
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar';
+import { uiT } from '@/lib/i18n';
export interface MenuItem {
icon?: React.ReactNode;
@@ -35,19 +36,19 @@ const menuItems: readonly MenuItem[] = [
icon: ,
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,
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({
{isCheckboxVisible ? (
@@ -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)}
- {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 && (
{busy ? : }
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 (
+ void i18n.changeLanguage(nextLanguage)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+ {t('language.action')}
+
+ );
+}
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"
>
editor.chain().focus().toggleBold().run()}
pressed={editor.isActive('bold')}
size="sm"
- title="Bold (Ctrl+B)"
+ title={uiT('Bold (Ctrl+B)')}
>
editor.chain().focus().toggleItalic().run()}
pressed={editor.isActive('italic')}
size="sm"
- title="Italic (Ctrl+I)"
+ title={uiT('Italic (Ctrl+I)')}
>
@@ -357,11 +358,11 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
editor.chain().focus().toggleCode().run()}
pressed={editor.isActive('code')}
size="sm"
- title="Inline code"
+ title={uiT('Inline code')}
>
@@ -372,29 +373,29 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
/>
editor.chain().focus().toggleHeading({ level: 1 }).run()}
pressed={editor.isActive('heading', { level: 1 })}
size="sm"
- title="Heading 1"
+ title={uiT('Heading 1')}
>
editor.chain().focus().toggleHeading({ level: 2 }).run()}
pressed={editor.isActive('heading', { level: 2 })}
size="sm"
- title="Heading 2"
+ title={uiT('Heading 2')}
>
editor.chain().focus().toggleHeading({ level: 3 }).run()}
pressed={editor.isActive('heading', { level: 3 })}
size="sm"
- title="Heading 3"
+ title={uiT('Heading 3')}
>
@@ -405,20 +406,20 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
/>
editor.chain().focus().toggleBulletList().run()}
pressed={editor.isActive('bulletList')}
size="sm"
- title="Bullet list"
+ title={uiT('Bullet list')}
>
editor.chain().focus().toggleOrderedList().run()}
pressed={editor.isActive('orderedList')}
size="sm"
- title="Ordered list"
+ title={uiT('Ordered list')}
>
@@ -429,60 +430,60 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
/>
editor.chain().focus().toggleBlockquote().run()}
pressed={editor.isActive('blockquote')}
size="sm"
- title="Blockquote"
+ title={uiT('Blockquote')}
>
editor.chain().focus().toggleCodeBlock().run()}
pressed={editor.isActive('codeBlock')}
size="sm"
- title="Code block"
+ title={uiT('Code block')}
>
editor.chain().focus().setHorizontalRule().run()}
pressed={false}
size="sm"
- title="Horizontal rule"
+ title={uiT('Horizontal rule')}
>
editor.chain().focus().undo().run()}
pressed={false}
size="sm"
- title="Undo (Ctrl+Z)"
+ title={uiT('Undo (Ctrl+Z)')}
>
editor.chain().focus().redo().run()}
pressed={false}
size="sm"
- title="Redo (Ctrl+Shift+Z)"
+ title={uiT('Redo (Ctrl+Shift+Z)')}
>
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 (
}
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 (
-
Loading...
+
{uiT('Loading...')}
);
}
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 =
,
- saveText = 'Save',
+ saveText = uiT('Save'),
title = 'Unsaved changes',
}: UnsavedChangesDialogProps) {
return (
@@ -72,7 +73,7 @@ function UnsavedChangesDialog({
onClick={handleCancel}
variant="outline"
>
- Cancel
+ {uiT('Cancel')}
*
*
- * No matches
+ * {uiT('No matches')}
*
* {paths.map((p) => (
* {p}
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}
>
- More
+ {uiT('More')}
);
}
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
- {hasFilter ? 'No matches' : `No ${entityName} yet`}
+
+ {hasFilter ? uiT('No matches') : uiT('No {{entity}} yet', { entity: uiT(entityName) })}
+
{hasFilter ? (
- No {entityName} match {filterValue}. Try a different query.
+ {uiT('No {{entity}} match {{query}}. Try a different query.', {
+ entity: uiT(entityName),
+ query: filterValue,
+ })}
) : null}
@@ -627,7 +633,7 @@ function DataTable({
({
({
{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')
)}
- Rows per page
+ {uiT('Rows per page')}
{
const pageSize = value === 'all' ? data.length : Number.parseInt(value, 10);
@@ -846,7 +856,7 @@ function DataTable({
{size}
))}
- All
+ {uiT('All')}
@@ -862,7 +872,7 @@ function DataTable({
)}
table.firstPage()}
size="icon-xs"
@@ -871,7 +881,7 @@ function DataTable({
table.previousPage()}
size="icon-xs"
@@ -880,7 +890,7 @@ function DataTable({
table.nextPage()}
size="icon-xs"
@@ -889,7 +899,7 @@ function DataTable({
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
- Close
+ {uiT('Close')}
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 (
- {isDragging ? 'Drop files here' : title}
+ {isDragging ? uiT('Drop files here') : title}
{description}
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 = '
>
- Close
+ {uiT('Close')}
{children}
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 (
) {
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
- Toggle Sidebar
+ {uiT('Toggle Sidebar')}
);
}
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}
>
- Loading...
+ {uiT('Loading...')}