From 11c80f74696fa8535d1357e7b6ae7780e1f6a3ab Mon Sep 17 00:00:00 2001 From: "samanhappy@qq.com" Date: Thu, 10 Apr 2025 22:40:11 +0800 Subject: [PATCH] feat: integrate i18next for internationalization support; add English and Chinese translations --- .eslintrc.json | 3 +- frontend/src/App.tsx | 16 ++--- frontend/src/components/AddServerForm.tsx | 6 +- frontend/src/components/EditServerForm.tsx | 9 ++- frontend/src/components/ServerCard.tsx | 8 ++- frontend/src/components/ServerForm.tsx | 26 +++++---- frontend/src/components/ui/Badge.tsx | 12 +++- frontend/src/components/ui/DeleteDialog.tsx | 12 ++-- frontend/src/i18n.ts | 42 +++++++++++++ frontend/src/locales/en.json | 41 +++++++++++++ frontend/src/locales/zh.json | 41 +++++++++++++ frontend/src/main.tsx | 2 + package.json | 3 + pnpm-lock.yaml | 65 +++++++++++++++++++++ 14 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/zh.json diff --git a/.eslintrc.json b/.eslintrc.json index 66548e7..f742495 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,6 +19,7 @@ "varsIgnorePattern": "^_" } ], - "no-undef": "off" + "@typescript-eslint/no-explicit-any": "off", + "no-undef": "off", } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 680b66a..2aaf293 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { Server, ApiResponse } from './types' import ServerCard from './components/ServerCard' import AddServerForm from './components/AddServerForm' import EditServerForm from './components/EditServerForm' function App() { + const { t } = useTranslation() const [servers, setServers] = useState([]) const [error, setError] = useState(null) const [refreshKey, setRefreshKey] = useState(0) @@ -67,7 +69,7 @@ function App() { setEditingServer(fullServerData) } else { console.error('Failed to get server config from settings:', settingsData) - setError(`Could not find configuration data for ${server.name}`) + setError(t('server.invalidConfig', { serverName: server.name })) } }) .catch(err => { @@ -89,13 +91,13 @@ function App() { const result = await response.json() if (!response.ok) { - setError(result.message || `Failed to delete server ${serverName}`) + setError(result.message || t('server.deleteError', { serverName })) return } setRefreshKey(prevKey => prevKey + 1) } catch (err) { - setError('Error: ' + (err instanceof Error ? err.message : String(err))) + setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err))) } } @@ -104,13 +106,13 @@ function App() {
-

Error

+

{t('app.error')}

{error}

@@ -122,12 +124,12 @@ function App() {
-

MCP Hub Dashboard

+

{t('app.title')}

{servers.length === 0 ? (
-

No MCP servers available

+

{t('app.noServers')}

) : (
diff --git a/frontend/src/components/AddServerForm.tsx b/frontend/src/components/AddServerForm.tsx index 1499cce..5af373b 100644 --- a/frontend/src/components/AddServerForm.tsx +++ b/frontend/src/components/AddServerForm.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import ServerForm from './ServerForm' interface AddServerFormProps { @@ -6,6 +7,7 @@ interface AddServerFormProps { } const AddServerForm = ({ onAdd }: AddServerFormProps) => { + const { t } = useTranslation() const [modalVisible, setModalVisible] = useState(false) const toggleModal = () => { @@ -40,12 +42,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => { onClick={toggleModal} className="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded" > - Add New Server + {t('server.addServer')} {modalVisible && (
- +
)}
diff --git a/frontend/src/components/EditServerForm.tsx b/frontend/src/components/EditServerForm.tsx index 5659ed4..038e402 100644 --- a/frontend/src/components/EditServerForm.tsx +++ b/frontend/src/components/EditServerForm.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { Server } from '@/types' import ServerForm from './ServerForm' @@ -8,6 +9,8 @@ interface EditServerFormProps { } const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => { + const { t } = useTranslation() + const handleSubmit = async (payload: any) => { try { const response = await fetch(`/api/servers/${server.name}`, { @@ -19,13 +22,13 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => { const result = await response.json() if (!response.ok) { - alert(result.message || 'Failed to update server') + alert(result.message || t('server.updateError', 'Failed to update server')) return } onEdit() } catch (err) { - alert(`Error: ${err instanceof Error ? err.message : String(err)}`) + alert(`${t('errors.general')}: ${err instanceof Error ? err.message : String(err)}`) } } @@ -35,7 +38,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => { onSubmit={handleSubmit} onCancel={onCancel} initialData={server} - modalTitle={`Edit Server: ${server.name}`} + modalTitle={t('server.editTitle', {serverName: server.name})} />
) diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx index e8b707e..46d6e55 100644 --- a/frontend/src/components/ServerCard.tsx +++ b/frontend/src/components/ServerCard.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { Server } from '@/types' import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons' import Badge from '@/components/ui/Badge' @@ -12,6 +13,7 @@ interface ServerCardProps { } const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => { + const { t } = useTranslation() const [isExpanded, setIsExpanded] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) @@ -45,13 +47,13 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => { onClick={handleEdit} className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm" > - Edit + {t('server.edit')}
{envVars.map((envVar, index) => ( @@ -222,7 +224,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv value={envVar.key} onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)} className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2" - placeholder="key" + placeholder={t('server.key')} /> : handleEnvVarChange(index, 'value', e.target.value)} className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2" - placeholder="value" + placeholder={t('server.value')} />
))} @@ -252,13 +254,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv onClick={onCancel} className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2" > - Cancel + {t('server.cancel')} diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx index 560b3d5..af95582 100644 --- a/frontend/src/components/ui/Badge.tsx +++ b/frontend/src/components/ui/Badge.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { ServerStatus } from '@/types' interface BadgeProps { @@ -5,17 +6,26 @@ interface BadgeProps { } const Badge = ({ status }: BadgeProps) => { + const { t } = useTranslation() + const colors = { connecting: 'bg-yellow-100 text-yellow-800', connected: 'bg-green-100 text-green-800', disconnected: 'bg-red-100 text-red-800', } + // Map status to translation keys + const statusTranslations = { + connected: 'status.online', + disconnected: 'status.offline', + connecting: 'status.connecting' + } + return ( - {status} + {t(statusTranslations[status] || status)} ) } diff --git a/frontend/src/components/ui/DeleteDialog.tsx b/frontend/src/components/ui/DeleteDialog.tsx index 952d27d..ca20a01 100644 --- a/frontend/src/components/ui/DeleteDialog.tsx +++ b/frontend/src/components/ui/DeleteDialog.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next' + interface DeleteDialogProps { isOpen: boolean onClose: () => void @@ -6,27 +8,29 @@ interface DeleteDialogProps { } const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => { + const { t } = useTranslation() + if (!isOpen) return null return (
-

Delete Server

+

{t('server.delete')}

- Are you sure you want to delete server {serverName}? This action cannot be undone. + {t('server.confirmDelete')} {serverName}

diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 0000000..d588bfa --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,42 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +// Import translations +import enTranslation from './locales/en.json'; +import zhTranslation from './locales/zh.json'; + +i18n + // Detect user language + .use(LanguageDetector) + // Pass the i18n instance to react-i18next + .use(initReactI18next) + // Initialize i18next + .init({ + resources: { + en: { + translation: enTranslation + }, + zh: { + translation: zhTranslation + } + }, + fallbackLng: 'en', + debug: process.env.NODE_ENV === 'development', + + // Common namespace used for all translations + defaultNS: 'translation', + + interpolation: { + escapeValue: false, // React already safe from XSS + }, + + detection: { + // Order of detection; we put 'navigator' first to use browser language + order: ['navigator', 'localStorage', 'cookie', 'htmlTag'], + // Cache the language in localStorage + caches: ['localStorage', 'cookie'], + } + }); + +export default i18n; \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000..3619a3f --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,41 @@ +{ + "app": { + "title": "MCP Hub Dashboard", + "error": "Error", + "closeButton": "Close", + "noServers": "No MCP servers available" + }, + "server": { + "addServer": "Add Server", + "add": "Add", + "edit": "Edit", + "delete": "Delete", + "confirmDelete": "Are you sure you want to delete this server?", + "status": "Status", + "tools": "Tools", + "name": "Server Name", + "url": "Server URL", + "apiKey": "API Key", + "save": "Save Changes", + "cancel": "Cancel", + "invalidConfig": "Could not find configuration data for {{serverName}}", + "deleteError": "Failed to delete server {{serverName}}", + "updateError": "Failed to update server", + "editTitle": "Edit Server: {{serverName}}", + "type": "Server Type", + "command": "Command", + "arguments": "Arguments", + "envVars": "Environment Variables", + "key": "key", + "value": "value", + "remove": "Remove" + }, + "status": { + "online": "Online", + "offline": "Offline", + "connecting": "Connecting" + }, + "errors": { + "general": "Something went wrong" + } +} \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json new file mode 100644 index 0000000..0e6a087 --- /dev/null +++ b/frontend/src/locales/zh.json @@ -0,0 +1,41 @@ +{ + "app": { + "title": "MCP Hub 控制面板", + "error": "错误", + "closeButton": "关闭", + "noServers": "没有可用的 MCP 服务器" + }, + "server": { + "addServer": "添加服务器", + "add": "添加", + "edit": "编辑", + "delete": "删除", + "confirmDelete": "您确定要删除此服务器吗?", + "status": "状态", + "tools": "工具", + "name": "服务器名称", + "url": "服务器 URL", + "apiKey": "API 密钥", + "save": "保存更改", + "cancel": "取消", + "invalidConfig": "无法找到 {{serverName}} 的配置数据", + "deleteError": "删除服务器 {{serverName}} 失败", + "updateError": "更新服务器失败", + "editTitle": "编辑服务器: {{serverName}}", + "type": "服务器类型", + "command": "命令", + "arguments": "参数", + "envVars": "环境变量", + "key": "键", + "value": "值", + "remove": "移除" + }, + "status": { + "online": "在线", + "offline": "离线", + "connecting": "连接中" + }, + "errors": { + "general": "发生错误" + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2fbbbc1..378fa92 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,8 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import './index.css' +// Import the i18n configuration +import './i18n' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/package.json b/package.json index 947cd22..116596d 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,14 @@ "clsx": "^2.1.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "i18next": "^24.2.3", + "i18next-browser-languagedetector": "^8.0.4", "lucide-react": "^0.486.0", "next": "^15.2.4", "postcss": "^8.5.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^15.4.1", "tailwind-merge": "^3.1.0", "tailwind-scrollbar-hide": "^2.0.0", "tailwindcss": "^4.0.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7a514e..6602a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: express: specifier: ^4.18.2 version: 4.21.2 + i18next: + specifier: ^24.2.3 + version: 24.2.3(typescript@5.8.2) + i18next-browser-languagedetector: + specifier: ^8.0.4 + version: 8.0.4 lucide-react: specifier: ^0.486.0 version: 0.486.0(react@19.1.0) @@ -59,6 +65,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + react-i18next: + specifier: ^15.4.1 + version: 15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.1.0 version: 3.1.0 @@ -2163,6 +2172,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -2175,6 +2187,17 @@ packages: resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} engines: {node: '>=14.18.0'} + i18next-browser-languagedetector@8.0.4: + resolution: {integrity: sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==} + + i18next@24.2.3: + resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2934,6 +2957,19 @@ packages: peerDependencies: react: ^19.1.0 + react-i18next@15.4.1: + resolution: {integrity: sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3411,6 +3447,10 @@ packages: terser: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -5575,6 +5615,10 @@ snapshots: html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -5587,6 +5631,16 @@ snapshots: human-signals@4.3.1: {} + i18next-browser-languagedetector@8.0.4: + dependencies: + '@babel/runtime': 7.27.0 + + i18next@24.2.3(typescript@5.8.2): + dependencies: + '@babel/runtime': 7.27.0 + optionalDependencies: + typescript: 5.8.2 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -6431,6 +6485,15 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 + react-i18next@15.4.1(i18next@24.2.3(typescript@5.8.2))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.0 + html-parse-stringify: 3.0.1 + i18next: 24.2.3(typescript@5.8.2) + react: 19.1.0 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-is@18.3.1: {} react-refresh@0.14.2: {} @@ -6919,6 +6982,8 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.29.2 + void-elements@3.1.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12