From 876b1518da3efa51e7b95ccf22e7d971a501e09b Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Thu, 9 Apr 2026 10:03:54 +0300 Subject: [PATCH] feat(client): add i18n with i18next and react-i18next - install i18next and react-i18next - create src/i18n/index.ts bootstrapping i18next with initReactI18next - add src/i18n/locales/en.json with keys for nav, all pages, and ThemeSwitcher - replace hardcoded English strings in App, all pages, and ThemeSwitcher with t() - move static column definitions inside components where useTranslation is available --- client/package.json | 4 +- client/src/App.tsx | 16 ++-- client/src/i18n/index.ts | 14 +++ client/src/i18n/locales/en.json | 45 ++++++++++ client/src/main.tsx | 1 + client/src/pages/EnvironmentsPage.tsx | 46 +++++----- client/src/pages/KeysPage.tsx | 14 +-- client/src/pages/ScenariosPage.tsx | 16 ++-- client/src/pages/SessionsPage.tsx | 16 ++-- client/src/ui/ThemeSwitcher/ThemeSwitcher.tsx | 6 +- package-lock.json | 85 +++++++++++++++++-- 11 files changed, 207 insertions(+), 56 deletions(-) create mode 100644 client/src/i18n/index.ts create mode 100644 client/src/i18n/locales/en.json diff --git a/client/package.json b/client/package.json index e1f0373..f96fc84 100644 --- a/client/package.json +++ b/client/package.json @@ -11,9 +11,11 @@ "build-storybook": "storybook build" }, "dependencies": { + "i18next": "^26.0.4", "lucide-react": "^1.7.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-i18next": "^17.0.2" }, "devDependencies": { "@storybook/addon-a11y": "^10.3.5", diff --git a/client/src/App.tsx b/client/src/App.tsx index 179877e..273b4a8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,18 +5,22 @@ import { KeysPage } from './pages/KeysPage'; import { SessionsPage } from './pages/SessionsPage'; import { ScenariosPage } from './pages/ScenariosPage'; import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; type Section = 'environments' | 'keys' | 'sessions' | 'scenarios'; -const NAV: { id: Section; label: string }[] = [ - { id: 'environments', label: 'Environments' }, - { id: 'keys', label: 'Keys' }, - { id: 'sessions', label: 'Sessions' }, - { id: 'scenarios', label: 'Scenarios' }, -]; + export default function App() { const [active, setActive] = useState
('scenarios'); + const { t } = useTranslation(); + + const NAV: { id: Section; label: string }[] = [ + { id: 'environments', label: t('nav.environments') }, + { id: 'keys', label: t('nav.keys') }, + { id: 'sessions', label: t('nav.sessions') }, + { id: 'scenarios', label: t('nav.scenarios') }, + ]; return (
diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts new file mode 100644 index 0000000..2e1ee67 --- /dev/null +++ b/client/src/i18n/index.ts @@ -0,0 +1,14 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import en from './locales/en.json'; + +i18n + .use(initReactI18next) + .init({ + resources: { en: { translation: en } }, + lng: 'en', + fallbackLng: 'en', + interpolation: { escapeValue: false }, + }); + +export default i18n; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json new file mode 100644 index 0000000..c49801c --- /dev/null +++ b/client/src/i18n/locales/en.json @@ -0,0 +1,45 @@ +{ + "nav": { + "environments": "Environments", + "keys": "Keys", + "sessions": "Sessions", + "scenarios": "Scenarios" + }, + "environments": { + "title": "Environments", + "col_id": "ID", + "col_name": "Name", + "col_urls": "URLs", + "col_updated": "Updated", + "empty": "No environments yet." + }, + "keys": { + "title": "Keys", + "col_name": "Key name", + "empty": "No key files found." + }, + "sessions": { + "title": "Sessions", + "col_id": "ID", + "col_name": "Name", + "col_status": "Status", + "col_lastUsed": "Last Used", + "empty": "No sessions yet.", + "action_delete": "Delete" + }, + "scenarios": { + "title": "Scenarios", + "col_id": "ID", + "col_name": "Name", + "col_updated": "Updated", + "empty": "No scenarios yet.", + "action_run": "Run", + "action_delete": "Delete" + }, + "theme": { + "switch_to_light": "Switch to light theme", + "switch_to_dark": "Switch to dark theme", + "light_mode": "Light mode", + "dark_mode": "Dark mode" + } +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 19d49f8..1556fa7 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import './i18n' import './ui/tokens.css' import App from './App' diff --git a/client/src/pages/EnvironmentsPage.tsx b/client/src/pages/EnvironmentsPage.tsx index 303d85e..3fa13b1 100644 --- a/client/src/pages/EnvironmentsPage.tsx +++ b/client/src/pages/EnvironmentsPage.tsx @@ -1,34 +1,36 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { environments } from '../api'; import type { Environment } from '../api'; import { Table, type TableColumn } from '../ui'; import styles from './Page.module.css'; -const columns: TableColumn[] = [ - { key: 'id', header: 'ID', render: (e) => e.id, width: 60 }, - { key: 'name', header: 'Name', render: (e) => e.name }, - { - key: 'urls', - header: 'URLs', - render: (e) => - Object.entries(e.urls) - .filter(([, v]) => v) - .map(([k, v]) => `${k}: ${v}`) - .join(' · ') || '—', - }, - { - key: 'updated', - header: 'Updated', - width: 120, - render: (e) => new Date(e.updatedAt).toLocaleDateString(), - }, -]; - export function EnvironmentsPage() { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const columns: TableColumn[] = [ + { key: 'id', header: t('environments.col_id'), render: (e) => e.id, width: 60 }, + { key: 'name', header: t('environments.col_name'), render: (e) => e.name }, + { + key: 'urls', + header: t('environments.col_urls'), + render: (e) => + Object.entries(e.urls) + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`) + .join(' · ') || '—', + }, + { + key: 'updated', + header: t('environments.col_updated'), + width: 120, + render: (e) => new Date(e.updatedAt).toLocaleDateString(), + }, + ]; + useEffect(() => { setLoading(true); environments.list() @@ -39,14 +41,14 @@ export function EnvironmentsPage() { return (
-

Environments

+

{t('environments.title')}

{error &&

{error}

} e.id} loading={loading} - emptyMessage="No environments yet." + emptyMessage={t('environments.empty')} /> ); diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx index caab7fe..8441022 100644 --- a/client/src/pages/KeysPage.tsx +++ b/client/src/pages/KeysPage.tsx @@ -1,19 +1,21 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { keys } from '../api'; import { Table, type TableColumn } from '../ui'; import styles from './Page.module.css'; interface KeyRow { name: string } -const columns: TableColumn[] = [ - { key: 'name', header: 'Key name', render: (k) => k.name }, -]; - export function KeysPage() { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const columns: TableColumn[] = [ + { key: 'name', header: t('keys.col_name'), render: (k) => k.name }, + ]; + useEffect(() => { setLoading(true); keys.list() @@ -24,14 +26,14 @@ export function KeysPage() { return (
-

Keys

+

{t('keys.title')}

{error &&

{error}

}
k.name} loading={loading} - emptyMessage="No key files found." + emptyMessage={t('keys.empty')} /> ); diff --git a/client/src/pages/ScenariosPage.tsx b/client/src/pages/ScenariosPage.tsx index 658fe2a..24f818c 100644 --- a/client/src/pages/ScenariosPage.tsx +++ b/client/src/pages/ScenariosPage.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { scenarios } from '../api'; import type { Scenario } from '../api'; import { Button, Table, type TableColumn } from '../ui'; import styles from './Page.module.css'; export function ScenariosPage() { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -29,11 +31,11 @@ export function ScenariosPage() { }; const columns: TableColumn[] = [ - { key: 'id', header: 'ID', render: (s) => s.id, width: 60 }, - { key: 'name', header: 'Name', render: (s) => s.name }, + { key: 'id', header: t('scenarios.col_id'), render: (s) => s.id, width: 60 }, + { key: 'name', header: t('scenarios.col_name'), render: (s) => s.name }, { key: 'updated', - header: 'Updated', + header: t('scenarios.col_updated'), width: 120, render: (s) => new Date(s.updatedAt).toLocaleDateString(), }, @@ -44,8 +46,8 @@ export function ScenariosPage() { align: 'right', render: (s) => ( - - + + ), }, @@ -53,14 +55,14 @@ export function ScenariosPage() { return (
-

Scenarios

+

{t('scenarios.title')}

{error &&

{error}

}
s.id} loading={loading} - emptyMessage="No scenarios yet." + emptyMessage={t('scenarios.empty')} /> ); diff --git a/client/src/pages/SessionsPage.tsx b/client/src/pages/SessionsPage.tsx index 00d84dd..11ac67a 100644 --- a/client/src/pages/SessionsPage.tsx +++ b/client/src/pages/SessionsPage.tsx @@ -1,10 +1,12 @@ import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { sessions } from '../api'; import type { Session } from '../api'; import { Badge, Button, Table, type TableColumn } from '../ui'; import styles from './Page.module.css'; export function SessionsPage() { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -25,11 +27,11 @@ export function SessionsPage() { }; const columns: TableColumn[] = [ - { key: 'id', header: 'ID', render: (s) => s.id, width: 60 }, - { key: 'name', header: 'Name', render: (s) => s.sessionName }, + { key: 'id', header: t('sessions.col_id'), render: (s) => s.id, width: 60 }, + { key: 'name', header: t('sessions.col_name'), render: (s) => s.sessionName }, { key: 'status', - header: 'Status', + header: t('sessions.col_status'), width: 100, render: (s) => ( @@ -39,7 +41,7 @@ export function SessionsPage() { }, { key: 'lastUsed', - header: 'Last Used', + header: t('sessions.col_lastUsed'), width: 160, render: (s) => s.lastUsedAt ? new Date(s.lastUsedAt).toLocaleString() : '—', }, @@ -50,7 +52,7 @@ export function SessionsPage() { align: 'right', render: (s) => ( ), }, @@ -58,14 +60,14 @@ export function SessionsPage() { return (
-

Sessions

+

{t('sessions.title')}

{error &&

{error}

}
s.id} loading={loading} - emptyMessage="No sessions yet." + emptyMessage={t('sessions.empty')} /> ); diff --git a/client/src/ui/ThemeSwitcher/ThemeSwitcher.tsx b/client/src/ui/ThemeSwitcher/ThemeSwitcher.tsx index 665910f..0381d1a 100644 --- a/client/src/ui/ThemeSwitcher/ThemeSwitcher.tsx +++ b/client/src/ui/ThemeSwitcher/ThemeSwitcher.tsx @@ -1,16 +1,18 @@ import { Sun, Moon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useTheme } from '../../hooks/useTheme'; import styles from './ThemeSwitcher.module.css'; export function ThemeSwitcher() { const { theme, toggleTheme } = useTheme(); + const { t } = useTranslation(); return ( diff --git a/package-lock.json b/package-lock.json index 1c704c5..4a8478e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,11 @@ "name": "liquio-qa-bot-client", "version": "0.0.1", "dependencies": { + "i18next": "^26.0.4", "lucide-react": "^1.7.0", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "react-i18next": "^17.0.2" }, "devDependencies": { "@storybook/addon-a11y": "^10.3.5", @@ -721,9 +723,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -8162,6 +8162,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -8192,6 +8201,37 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "26.0.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.4.tgz", + "integrity": "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -10999,6 +11039,33 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.2.tgz", + "integrity": "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -12837,7 +12904,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13053,7 +13120,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13327,6 +13393,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",