feat: enhance i18n support by adding new translation keys and updating existing ones

This commit is contained in:
htilssu
2025-05-07 12:04:01 +07:00
parent 1660d2abee
commit f9548f0811
11 changed files with 60 additions and 329 deletions

View File

@@ -4,6 +4,7 @@
"save_changes": "Save Changes",
"loading": "Loading...",
"error": "Error",
"creating": "Creating...",
"success": "Success"
},
"settings": {
@@ -50,7 +51,15 @@
"title": "Authenticator App Enabled",
"description": "Store the codes below somewhere safe. If you lose access to your authenticator app you can use these backup codes to sign in.",
"alert": "These codes will not be shown again."
},
"disable": {
"title": "Remove Authenticator App",
"description": "Removing your authenticator app will make your account less secure."
}
},
"email": {
"update_email": "Update Email",
"updated_successfully": "Your primary email has been updated."
}
},
"api_key_modal": {
@@ -72,7 +81,11 @@
"delete_api_key_desc": "All requests using the {{key}} key will be invalidated.",
"no_api_keys": "No API keys exist for this account.",
"last_used": "Last used:",
"never": "Never"
"never": "Never",
"key_description": "Description",
"key_description_description": "A description of this API key.",
"allowed_ips": "Allowed IPs",
"allowed_ips_description": "Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line."
},
"activity_log": {
"title": "Account Activity Log",

View File

@@ -3,6 +3,7 @@
"language": "Ngôn ngữ",
"save_changes": "Lưu thay đổi",
"loading": "Đang tải...",
"creating": "Đang tạo...",
"error": "Lỗi",
"success": "Thành công"
},
@@ -50,7 +51,15 @@
"title": "Đã bật ứng dụng xác thực",
"description": "Lưu trữ các mã dưới đây ở nơi an toàn. Nếu bạn mất quyền truy cập vào ứng dụng xác thực, bạn có thể sử dụng các mã dự phòng này để đăng nhập.",
"alert": "Các mã này sẽ không được hiển thị lại."
},
"disable": {
"title": "Xóa ứng dụng xác thực",
"description": "Việc xóa ứng dụng xác thực sẽ làm cho tài khoản của bạn kém an toàn hơn."
}
},
"email": {
"update_email": "Cập nhật email",
"updated_successfully": "Email chính của bạn đã được cập nhật."
}
},
"api_key_modal": {
@@ -80,6 +89,21 @@
"delete_confirm": "Xóa Key",
"delete_message": "Xóa SSH key {name} sẽ làm mất hiệu lực sử dụng của nó trên toàn bộ Panel."
},
"api": {
"account_api": "API tài khoản",
"create_api_key": "Tạo khóa API",
"api_keys": "Các khóa API",
"delete_api_key_title": "Xóa khóa API",
"delete_key": "Xóa khóa",
"delete_api_key_desc": "Tất cả các yêu cầu sử dụng khóa {{key}} sẽ bị vô hiệu hóa.",
"no_api_keys": "Không có khóa API nào tồn tại cho tài khoản này.",
"last_used": "Sử dụng lần cuối:",
"never": "Chưa bao giờ",
"key_description": "Mô tả",
"key_description_description": "Mô tả về khóa API này.",
"allowed_ips": "Các địa chỉ IP được phép",
"allowed_ips_description": "Để trống để cho phép bất kỳ địa chỉ IP nào sử dụng khóa API này, nếu không, cung cấp từng địa chỉ IP trên một dòng mới."
},
"server_titles": {
"schedules": "Lịch trình",
"users": "Người dùng",

View File

@@ -14,12 +14,6 @@ interface Props {
const Container = styled.div<{ $type?: FlashMessageType }>``;
Container.displayName = 'MessageBox.Container';
/**
* Component hiển thị thông báo với tiêu đề và nội dung
* @param title Tiêu đề thông báo (có thể là key i18n)
* @param children Nội dung thông báo (có thể là key i18n)
* @param type Loại thông báo: success, info, warning, error
*/
const MessageBox = ({ title, children, type }: Props) => {
const { t } = useTranslation();

View File

@@ -1,16 +1,8 @@
import { useTranslation } from 'react-i18next';
interface Props {
children: React.ReactNode;
}
/**
* Component cung cấp thông tin về phiên bản và môi trường Pyrodactyl
* @param children Nội dung bên trong provider
*/
const PyrodactylProvider = ({ children }: Props) => {
const { t } = useTranslation();
return (
<div
data-pyro-pyrodactylprovider=''

View File

@@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import Button from '@/components/elements/Button';
import Code from '@/components/elements/Code';
import ContentBox from '@/components/elements/ContentBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';

View File

@@ -1,7 +1,7 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { useState } from 'react';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { object, string } from 'yup';
import FlashMessageRender from '@/components/FlashMessageRender';
@@ -9,7 +9,6 @@ import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
import ContentBox from '@/components/elements/ContentBox';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Input from '@/components/elements/Input';
import PageContentBlock from '@/components/elements/PageContentBlock';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import { Button } from '@/components/elements/button/index';
@@ -25,6 +24,7 @@ interface Values {
}
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const { t } = useTranslation();
const [apiKey, setApiKey] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@@ -70,18 +70,18 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
{/* Description Field */}
<FormikFieldWrapper
label='Description'
label={t('api.key_description')}
name='description'
description='A description of this API key.'
description={t('api.key_description_description')}
>
<Field name='description' as={Input} className='w-full' />
</FormikFieldWrapper>
{/* Allowed IPs Field */}
<FormikFieldWrapper
label='Allowed IPs'
label={t('api.allowed_ips')}
name='allowedIps'
description='Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'
description={t('api.allowed_ips_description')}
>
<Field name='allowedIps' as={Input} className='w-full' />
</FormikFieldWrapper>
@@ -89,7 +89,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
{/* Submit Button below form fields */}
<div className='flex justify-end mt-6'>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create API Key'}
{isSubmitting ? t('common.creating') : t('api.create_api_key')}
</Button>
</div>
</Form>

View File

@@ -1,6 +1,7 @@
// FIXME: replace with radix tooltip
// import Tooltip from '@/components/elements/tooltip/Tooltip';
import { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Button } from '@/components/elements/button/index';
@@ -16,6 +17,7 @@ import { useStoreActions } from '@/state/hooks';
import { useFlashKey } from '@/plugins/useFlash';
const DisableTOTPDialog = () => {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [password, setPassword] = useState('');
const { clearAndAddHttpError } = useFlashKey('account:two-step');
@@ -47,7 +49,7 @@ const DisableTOTPDialog = () => {
<form id={'disable-totp-form'} className={'mt-6'} onSubmit={submit}>
<FlashMessageRender byKey={'account:two-step'} />
<label className={'block pb-1'} htmlFor={'totp-password'}>
Password
{t('account_password')}
</label>
<Input.Text
id={'totp-password'}
@@ -57,14 +59,14 @@ const DisableTOTPDialog = () => {
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer>
<Button.Text onClick={close}>Cancel</Button.Text>
<Button.Text onClick={close}>{t('cancel')}</Button.Text>
{/* <Tooltip
delay={100}
disabled={password.length > 0}
content={'You must enter your account password to continue.'}
> */}
<Button.Danger type={'submit'} form={'disable-totp-form'} disabled={submitting || !password.length}>
Disable
{t('settings.2fa.buttons.disable')}
</Button.Danger>
{/* </Tooltip> */}
</Dialog.Footer>

View File

@@ -109,7 +109,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Dialog.Footer>
<Button.Text onClick={close}>Cancel</Button.Text>
<Button.Text onClick={close}>{t('cancel')}</Button.Text>
{/* <Tooltip
disabled={password.length > 0 && value.length === 6}
content={

View File

@@ -17,11 +17,6 @@ interface Values {
password: string;
}
const schema = Yup.object().shape({
email: Yup.string().email().required(),
password: Yup.string().required('You must provide your current account password.'),
});
export default () => {
const { t } = useTranslation();
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
@@ -29,6 +24,11 @@ export default () => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const schema = Yup.object().shape({
email: Yup.string().email().required(t('auth.validation.email_required_reset')),
password: Yup.string().required(t('auth.validation.password_required')),
});
const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:email');
@@ -37,14 +37,14 @@ export default () => {
addFlash({
type: 'success',
key: 'account:email',
message: 'Your primary email has been updated.',
message: t('settings.email.updated_successfully'),
}),
)
.catch((error) =>
addFlash({
type: 'error',
key: 'account:email',
title: 'Error',
title: t('error'),
message: httpErrorToHuman(error),
}),
)
@@ -60,7 +60,7 @@ export default () => {
<Fragment>
<SpinnerOverlay size={'large'} visible={isSubmitting} />
<Form className={`m-0`}>
<Field id={'current_email'} type={'email'} name={'email'} label={'Email'} />
<Field id={'current_email'} type={'email'} name={'email'} label={t('auth.email')} />
<div className={`mt-6`}>
<Field
id={'confirm_password'}
@@ -70,7 +70,7 @@ export default () => {
/>
</div>
<div className={`mt-6`}>
<Button disabled={isSubmitting || !isValid}>Update Email</Button>
<Button disabled={isSubmitting || !isValid}>{t('settings.email.update_email')}</Button>
</div>
</Form>
</Fragment>

View File

@@ -1,232 +0,0 @@
# Hướng dẫn di chuyển từ chuỗi tĩnh sang i18n
## Giới thiệu
Tài liệu này cung cấp hướng dẫn để di chuyển mã nguồn của bạn từ việc sử dụng chuỗi tĩnh (hardcoded string) sang sử dụng i18n để hỗ trợ đa ngôn ngữ.
## Cấu trúc thư mục
Dự án sử dụng thư viện i18next và react-i18next với cấu trúc thư mục như sau:
```
/public/locales
├── en/
│ └── translation.json
├── vi/
│ └── translation.json
```
## Hướng dẫn di chuyển
### Bước 1: Import hook useTranslation
```tsx
import { useTranslation } from 'react-i18next';
```
### Bước 2: Sử dụng hook trong component
```tsx
const { t } = useTranslation();
```
### Bước 3: Thay thế chuỗi tĩnh bằng t function
Thay:
```tsx
<h1>Tên máy chủ</h1>
```
Bằng:
```tsx
<h1>{t('server.settings.rename.server_name')}</h1>
```
### Bước 4: Thêm khóa dịch vào file translation.json
Thêm vào `/public/locales/en/translation.json`:
```json
{
"server": {
"settings": {
"rename": {
"server_name": "Server Name"
}
}
}
}
```
Thêm vào `/public/locales/vi/translation.json`:
```json
{
"server": {
"settings": {
"rename": {
"server_name": "Tên máy chủ"
}
}
}
}
```
### Bước 5: Sử dụng variables trong chuỗi dịch
```tsx
// Trong component
<p>{t('welcome.message', { username: user.name })}</p>
// Trong file translation.json
{
"welcome": {
"message": "Xin chào, {{username}}!"
}
}
```
### Bước 6: Sử dụng Trans component cho HTML phức tạp
```tsx
import { Trans, useTranslation } from 'react-i18next';
const { t } = useTranslation();
<Trans i18nKey="description.part">
Đ biết thêm thông tin, <a href="https://example.com">nhấp vào đây</a>.
</Trans>
// Trong file translation.json
{
"description": {
"part": "Để biết thêm thông tin, <1>nhấp vào đây</1>."
}
}
```
## Danh sách component đã di chuyển
Các component sau đã được di chuyển để sử dụng i18n:
1. `ShellContainer.tsx`
2. `ReinstallServerBox.tsx`
3. `SettingsContainer.tsx`
4. `RenameServerBox.tsx`
5. `ScheduleContainer.tsx`
6. `AccountApiContainer.tsx`
7. `UsersContainer.tsx`
8. `ActivityLogContainer.tsx`
9. `AccountOverviewContainer.tsx`
10. `ConfigureTwoFactorForm.tsx`
11. `ApiKeyModal.tsx`
12. `RecoveryTokensDialog.tsx`
13. `CreateSSHKeyForm.tsx`
14. `ConfirmationModal.tsx`
15. `SetupTOTPDialog.tsx`
16. `UpdateEmailAddressForm.tsx`
17. `UpdateLanguageForm.tsx`
18. `ConfirmationDialog.tsx`
19. `UpdatePasswordForm.tsx`
20. `Modal.tsx`
21. `PageContentBlock.tsx`
22. `PermissionRoute.tsx`
23. `ScreenBlock.tsx` (NotFound component)
## Danh sách component cần di chuyển
Các component sau cần được di chuyển để sử dụng i18n:
1. `ScreenBlock.tsx` (phần ScreenBlock & ServerError component)
2. `MessageBox.tsx`
3. `FlashMessageRender.tsx`
4. `App.tsx` (phần Suspense "Loading...")
## Ví dụ di chuyển
### Ví dụ 1: Component MessageBox
**Trước khi di chuyển:**
```tsx
const MessageBox = ({ title, children, type }: Props) => (
<Container
className='flex flex-col gap-2 bg-black border-[2px] border-brand/70 p-4 rounded-2xl mb-4'
$type={type}
role={'alert'}
>
{title && <h2 className='font-bold text-xl'>{title}</h2>}
<Code>{children}</Code>
</Container>
);
```
**Sau khi di chuyển:**
```tsx
import { useTranslation } from 'react-i18next';
const MessageBox = ({ title, children, type }: Props) => {
const { t } = useTranslation();
return (
<Container
className='flex flex-col gap-2 bg-black border-[2px] border-brand/70 p-4 rounded-2xl mb-4'
$type={type}
role={'alert'}
>
{title && <h2 className='font-bold text-xl'>{t(title)}</h2>}
<Code>{t(children)}</Code>
</Container>
);
};
```
### Ví dụ 2: Component ScreenBlock
**Trước khi di chuyển:**
```tsx
const ScreenBlock = ({ title, message }) => {
return (
<>
<div className='w-full h-full flex gap-12 items-center p-8 max-w-3xl mx-auto'>
<div className='flex flex-col gap-8 max-w-sm text-left'>
<h1 className='text-[32px] font-extrabold leading-[98%] tracking-[-0.11rem]'>{title}</h1>
<p className=''>{message}</p>
</div>
</div>
</>
);
};
```
**Sau khi di chuyển:**
```tsx
import { useTranslation } from 'react-i18next';
const ScreenBlock = ({ title, message }) => {
const { t } = useTranslation();
return (
<>
<div className='w-full h-full flex gap-12 items-center p-8 max-w-3xl mx-auto'>
<div className='flex flex-col gap-8 max-w-sm text-left'>
<h1 className='text-[32px] font-extrabold leading-[98%] tracking-[-0.11rem]'>{t(title)}</h1>
<p className=''>{t(message)}</p>
</div>
</div>
</>
);
};
```
## Lưu ý quan trọng
1. **Cấu trúc khóa**: Sử dụng cấu trúc phân cấp cho khóa dịch, ví dụ: `component.action.message`
2. **Thống nhất cách sử dụng**: Đảm bảo sử dụng nhất quán `t` function trong toàn bộ ứng dụng
3. **Kiểm tra hỗ trợ đa ngôn ngữ**: Sau khi di chuyển, kiểm tra ứng dụng hoạt động chính xác trên tất cả ngôn ngữ được hỗ trợ
4. **Tham chiếu**: Xem code các component đã di chuyển để tham khảo cách thực hiện

View File

@@ -1,61 +0,0 @@
# Trạng thái di chuyển i18n
Tài liệu này theo dõi trạng thái di chuyển các component từ chuỗi tĩnh sang sử dụng i18n.
## Components đã di chuyển
| Component | Trạng thái | Ghi chú |
| ----------------------------- | ------------- | ------------- |
| ShellContainer.tsx | ✅ Hoàn thành | |
| ReinstallServerBox.tsx | ✅ Hoàn thành | |
| SettingsContainer.tsx | ✅ Hoàn thành | |
| RenameServerBox.tsx | ✅ Hoàn thành | |
| ScheduleContainer.tsx | ✅ Hoàn thành | |
| AccountApiContainer.tsx | ✅ Hoàn thành | |
| UsersContainer.tsx | ✅ Hoàn thành | |
| ActivityLogContainer.tsx | ✅ Hoàn thành | |
| AccountOverviewContainer.tsx | ✅ Hoàn thành | |
| ConfigureTwoFactorForm.tsx | ✅ Hoàn thành | |
| ApiKeyModal.tsx | ✅ Hoàn thành | |
| RecoveryTokensDialog.tsx | ✅ Hoàn thành | |
| CreateSSHKeyForm.tsx | ✅ Hoàn thành | |
| ConfirmationModal.tsx | ✅ Hoàn thành | |
| SetupTOTPDialog.tsx | ✅ Hoàn thành | |
| UpdateEmailAddressForm.tsx | ✅ Hoàn thành | |
| UpdateLanguageForm.tsx | ✅ Hoàn thành | |
| ConfirmationDialog.tsx | ✅ Hoàn thành | |
| UpdatePasswordForm.tsx | ✅ Hoàn thành | |
| Modal.tsx | ✅ Hoàn thành | |
| PageContentBlock.tsx | ✅ Hoàn thành | |
| PermissionRoute.tsx | ✅ Hoàn thành | |
| ScreenBlock.tsx (NotFound) | ✅ Hoàn thành | |
| ScreenBlock.tsx (ScreenBlock) | ✅ Hoàn thành | Mới di chuyển |
| ScreenBlock.tsx (ServerError) | ✅ Hoàn thành | Mới di chuyển |
| MessageBox.tsx | ✅ Hoàn thành | Mới di chuyển |
| FlashMessageRender.tsx | ✅ Hoàn thành | Mới di chuyển |
| App.tsx (Loading) | ✅ Hoàn thành | Mới di chuyển |
| LoginContainer.tsx | ✅ Hoàn thành | Mới di chuyển |
| ResetPasswordContainer.tsx | ✅ Hoàn thành | Mới di chuyển |
| ForgotPasswordContainer.tsx | ✅ Hoàn thành | Mới di chuyển |
| LoginCheckpointContainer.tsx | ✅ Hoàn thành | Mới di chuyển |
| LoginFormContainer.tsx | ✅ Hoàn thành | Mới di chuyển |
| AuthenticatedRoute.tsx | ✅ Hoàn thành | Mới di chuyển |
| ErrorBoundary.tsx | ✅ Hoàn thành | Mới di chuyển |
| PyrodactylProvider.tsx | ✅ Hoàn thành | Mới di chuyển |
## Files JSON đa ngôn ngữ
Cập nhật các khóa dịch trong files:
- `/public/locales/en/translation.json`: Đã cập nhật ✅
- `/public/locales/vi/translation.json`: Đã cập nhật ✅
## Tiến độ chung
- Tổng số components: 36
- Số components đã di chuyển: 36 (100%)
- Số components chờ di chuyển: 0 (0%)
## Hướng dẫn di chuyển
Xem chi tiết cách thực hiện di chuyển trong file [migration-guide.md](./migration-guide.md).