feat: integrate i18next for internationalization support; add English and Chinese translations

This commit is contained in:
samanhappy@qq.com
2025-04-10 22:40:11 +08:00
parent 4cf1bfaadd
commit 11c80f7469
14 changed files with 253 additions and 33 deletions

View File

@@ -19,6 +19,7 @@
"varsIgnorePattern": "^_"
}
],
"no-undef": "off"
"@typescript-eslint/no-explicit-any": "off",
"no-undef": "off",
}
}

View File

@@ -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<Server[]>([])
const [error, setError] = useState<string | null>(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() {
<div className="min-h-screen bg-red-50 p-8">
<div className="max-w-3xl mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-red-600 text-xl font-semibold">Error</h2>
<h2 className="text-red-600 text-xl font-semibold">{t('app.error')}</h2>
<p className="text-gray-600 mt-2">{error}</p>
<button
onClick={() => setError(null)}
className="mt-4 bg-red-100 text-red-800 py-1 px-3 rounded hover:bg-red-200"
>
Close
{t('app.closeButton')}
</button>
</div>
</div>
@@ -122,12 +124,12 @@ function App() {
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">MCP Hub Dashboard</h1>
<h1 className="text-3xl font-bold text-gray-900">{t('app.title')}</h1>
<AddServerForm onAdd={handleServerAdd} />
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">No MCP servers available</p>
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">

View File

@@ -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')}
</button>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm onSubmit={handleSubmit} onCancel={toggleModal} modalTitle="Add New Server" />
<ServerForm onSubmit={handleSubmit} onCancel={toggleModal} modalTitle={t('server.addServer')} />
</div>
)}
</div>

View File

@@ -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})}
/>
</div>
)

View File

@@ -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')}
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
Delete
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
@@ -68,7 +70,7 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Available Tools</h3>
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.tools')}</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server, EnvVar, ServerFormData } from '@/types'
interface ServerFormProps {
@@ -9,6 +10,7 @@ interface ServerFormProps {
}
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: ServerFormProps) => {
const { t } = useTranslation()
const [serverType, setServerType] = useState<'sse' | 'stdio'>(
initialData && initialData.config && initialData.config.url ? 'sse' : 'stdio',
)
@@ -107,7 +109,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
Server Name
{t('server.name')}
</label>
<input
type="text"
@@ -123,7 +125,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">Server Type</label>
<label className="block text-gray-700 text-sm font-bold mb-2">{t('server.type')}</label>
<div className="flex space-x-4">
<div>
<input
@@ -155,7 +157,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
{serverType === 'sse' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="url">
Server URL
{t('server.url')}
</label>
<input
type="url"
@@ -172,7 +174,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="command">
Command
{t('server.command')}
</label>
<input
type="text"
@@ -187,7 +189,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="arguments">
Arguments
{t('server.arguments')}
</label>
<input
type="text"
@@ -204,14 +206,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
Environment Variables
{t('server.envVars')}
</label>
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
>
+ Add
+ {t('server.add')}
</button>
</div>
{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')}
/>
<span className="flex items-center">:</span>
<input
@@ -230,7 +232,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
value={envVar.value}
onChange={(e) => 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')}
/>
</div>
<button
@@ -238,7 +240,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle }: Serv
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
>
- Remove
- {t('server.remove')}
</button>
</div>
))}
@@ -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')}
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
>
{isEdit ? 'Save Changes' : 'Add Server'}
{isEdit ? t('server.save') : t('server.add')}
</button>
</div>
</form>

View File

@@ -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 (
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
>
{status}
{t(statusTranslations[status] || status)}
</span>
)
}

View File

@@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">Delete Server</h3>
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.delete')}</h3>
<p className="text-gray-700">
Are you sure you want to delete server <strong>{serverName}</strong>? This action cannot be undone.
{t('server.confirmDelete')} <strong>{serverName}</strong>
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
Cancel
{t('server.cancel')}
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
Delete
{t('server.delete')}
</button>
</div>
</div>

42
frontend/src/i18n.ts Normal file
View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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": "发生错误"
}
}

View File

@@ -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(
<React.StrictMode>

View File

@@ -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",

65
pnpm-lock.yaml generated
View File

@@ -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