feat: implement i18n support and update UI components for localization

This commit is contained in:
htilssu
2025-05-03 11:42:13 +07:00
parent d5adc15761
commit 842f202f35
27 changed files with 348 additions and 137 deletions

4
.gitignore vendored
View File

@@ -45,3 +45,7 @@ nix/docker/maria/mariadb_data/
nix/mariadb/
wings/
mariadb_data/
docker-compose.yml
srv
.cursor

View File

@@ -2,6 +2,7 @@ import { faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { format } from 'date-fns';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
@@ -18,6 +19,7 @@ import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
import { useFlashKey } from '@/plugins/useFlash';
export default () => {
const { t } = useTranslation();
const [deleteIdentifier, setDeleteIdentifier] = useState('');
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
@@ -44,29 +46,29 @@ export default () => {
};
return (
<PageContentBlock title={'Account API'}>
<PageContentBlock title={t('api.account_api')}>
{/* Flash messages will now appear at the top of the page */}
<FlashMessageRender byKey='account' />
<div className='md:flex flex-nowrap my-10 space-x-8'>
<ContentBox title={'Create API Key'} className='flex-none w-full md:w-1/1'>
<ContentBox title={t('api.create_api_key')} className='flex-none w-full md:w-1/1'>
<CreateApiKeyForm onKeyCreated={(key) => setKeys((s) => [...s!, key])} />
</ContentBox>
</div>
<ContentBox title={'API Keys'}>
<ContentBox title={t('api.api_keys')}>
<SpinnerOverlay visible={loading} />
<Dialog.Confirm
title={'Delete API Key'}
confirm={'Delete Key'}
title={t('api.delete_api_key_title')}
confirm={t('api.delete_key')}
open={!!deleteIdentifier}
onClose={() => setDeleteIdentifier('')}
onConfirmed={() => doDeletion(deleteIdentifier)}
>
All requests using the <Code>{deleteIdentifier}</Code> key will be invalidated.
{t('api.delete_api_key_desc', { key: deleteIdentifier })}
</Dialog.Confirm>
{keys.length === 0 ? (
<p className='text-center text-sm text-gray-500'>
{loading ? 'Loading...' : 'No API keys exist for this account.'}
{loading ? t('common.loading') : t('api.no_api_keys')}
</p>
) : (
keys.map((key) => (
@@ -75,8 +77,8 @@ export default () => {
<div className='flex-1'>
<p className='text-sm font-medium'>{key.description}</p>
<p className='text-xs text-gray-500 uppercase'>
Last used:{' '}
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM d, yyyy HH:mm') : 'Never'}
{t('api.last_used')}{' '}
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM d, yyyy HH:mm') : t('api.never')}
</p>
</div>
<p className='text-sm text-gray-600 hidden md:block'>

View File

@@ -1,8 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import MessageBox from '@/components/MessageBox';
import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
import UpdateLanguageForm from '@/components/dashboard/forms/UpdateLanguageForm';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import ContentBox from '@/components/elements/ContentBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
@@ -10,38 +12,42 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import Code from '../elements/Code';
export default () => {
const { t } = useTranslation();
const { state } = useLocation();
return (
<PageContentBlock title={'Your Settings'}>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem] mb-8'>Your Settings</h1>
<PageContentBlock title={t('settings.your_settings')}>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem] mb-8'>
{t('settings.your_settings')}
</h1>
{state?.twoFactorRedirect && (
<MessageBox title={'2-Factor Required'} type={'error'}>
Your account must have two-factor authentication enabled in order to continue.
<MessageBox title={t('settings.two_factor_required')} type={'error'}>
{t('settings.two_factor_error')}
</MessageBox>
)}
<div className='flex flex-col w-full h-full gap-4'>
<h2 className='mt-8 font-extrabold text-2xl'>Account Information</h2>
<ContentBox title={'Email Address'} showFlashes={'account:email'}>
<h2 className='mt-8 font-extrabold text-2xl'>{t('settings.account_info')}</h2>
<ContentBox title={t('settings.email_address')} showFlashes={'account:email'}>
<UpdateEmailAddressForm />
</ContentBox>
<h2 className='mt-8 font-extrabold text-2xl'>Password and Authentication</h2>
<ContentBox title={'Account Password'} showFlashes={'account:password'}>
<h2 className='mt-8 font-extrabold text-2xl'>{t('settings.password_auth')}</h2>
<ContentBox title={t('settings.account_password')} showFlashes={'account:password'}>
<UpdatePasswordForm />
</ContentBox>
<ContentBox title={'Multi-Factor Authentication'}>
<ContentBox title={t('settings.language')}>
<UpdateLanguageForm />
</ContentBox>
<ContentBox title={t('settings.multi_factor_auth')}>
<ConfigureTwoFactorForm />
</ContentBox>
<h2 className='mt-8 font-extrabold text-2xl'>App</h2>
<ContentBox title={'Panel Version'}>
<p className='text-sm mb-4'>
This is useful to provide Pyro staff if you run into an unexpected issue.
</p>
<h2 className='mt-8 font-extrabold text-2xl'>{t('settings.app')}</h2>
<ContentBox title={t('settings.panel_version')}>
<p className='text-sm mb-4'>{t('settings.panel_version_desc')}</p>
<div className='flex flex-col gap-4'>
<Code>{import.meta.env.VITE_PYRODACTYL_VERSION}</Code>
<Code>
Build {import.meta.env.VITE_PYRODACTYL_BUILD_NUMBER}, Commit{' '}
{t('build')} {import.meta.env.VITE_PYRODACTYL_BUILD_NUMBER}, {t('commit')}{' '}
{import.meta.env.VITE_COMMIT_HASH.slice(0, 7)}
</Code>
</div>

View File

@@ -1,5 +1,6 @@
import ModalContext from '@/context/ModalContext';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button';
@@ -13,6 +14,7 @@ interface Props {
const ApiKeyModal = ({ apiKey }: Props) => {
const { dismiss } = useContext(ModalContext);
const { t } = useTranslation();
return (
<div className='p-6 space-y-6 max-w-lg mx-auto rounded-lg shadow-lg '>
@@ -20,10 +22,7 @@ const ApiKeyModal = ({ apiKey }: Props) => {
<FlashMessageRender byKey='account' />
{/* Modal Header */}
<p className='text-sm text-white-600 mt-2 '>
The API key you have requested is shown below. Please store it in a safe place, as it will not be shown
again.
</p>
<p className='text-sm text-white-600 mt-2 '>{t('api_key_modal.modal_desc')}</p>
{/* API Key Display Section */}
<div className='relative mt-6'>
@@ -44,7 +43,7 @@ const ApiKeyModal = ({ apiKey }: Props) => {
onClick={() => dismiss()}
className='bg-red-600 text-white hover:bg-red-700 px-6 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500'
>
Close
{t('api_key_modal.close')}
</Button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import FlashMessageRender from '@/components/FlashMessageRender';
@@ -17,6 +18,7 @@ import { useFlashKey } from '@/plugins/useFlash';
import useLocationHash from '@/plugins/useLocationHash';
export default () => {
const { t } = useTranslation();
const { hash } = useLocationHash();
const { clearAndAddHttpError } = useFlashKey('account');
const [filters, setFilters] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
@@ -34,8 +36,8 @@ export default () => {
}, [error]);
return (
<PageContentBlock title={'Account Activity Log'}>
<ContentBox title='Account Activity Log'>
<PageContentBlock title={t('activity_log.title')}>
<ContentBox title={t('activity_log.title')}>
<FlashMessageRender byKey={'account'} />
{(filters.filters?.event || filters.filters?.ip) && (
<div className={'flex justify-end mb-2'}>
@@ -44,7 +46,7 @@ export default () => {
className={clsx(btnStyles.button, btnStyles.text, 'w-full sm:w-auto')}
onClick={() => setFilters((value) => ({ ...value, filters: {} }))}
>
Clear Filters
{t('activity_log.clear_filters')}
</Link>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useStoreState } from 'easy-peasy';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import DisableTOTPDialog from '@/components/dashboard/forms/DisableTOTPDialog';
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
@@ -15,6 +16,7 @@ export default () => {
const [visible, setVisible] = useState<'enable' | 'disable' | null>(null);
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
const { clearAndAddHttpError } = useFlashKey('account:two-step');
const { t } = useTranslation();
useEffect(() => {
return () => {
@@ -33,15 +35,15 @@ export default () => {
<RecoveryTokensDialog tokens={tokens} open={tokens.length > 0} onClose={() => setTokens([])} />
<DisableTOTPDialog open={visible === 'disable'} onClose={() => setVisible(null)} />
<p className={`text-sm`}>
{isEnabled
? 'Your account is protected by an authenticator app.'
: 'You have not configured an authenticator app.'}
{isEnabled ? t('settings.2fa.status.enabled') : t('settings.2fa.status.disabled')}
</p>
<div className={`mt-6`}>
{isEnabled ? (
<Button.Danger onClick={() => setVisible('disable')}>Remove Authenticator App</Button.Danger>
<Button.Danger onClick={() => setVisible('disable')}>
{t('settings.2fa.buttons.disable')}
</Button.Danger>
) : (
<Button onClick={() => setVisible('enable')}>Enable Authenticator App</Button>
<Button onClick={() => setVisible('enable')}>{t('settings.2fa.buttons.enable')}</Button>
)}
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { Trans, useTranslation } from 'react-i18next';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { Alert } from '@/components/elements/alert';
import { Button } from '@/components/elements/button/index';
@@ -8,6 +10,7 @@ interface RecoveryTokenDialogProps extends DialogProps {
}
export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
const { t } = useTranslation();
const grouped = [] as [string, string][];
tokens.forEach((token, index) => {
if (index % 2 === 0) {
@@ -19,10 +22,8 @@ export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
<Dialog
open={open}
onClose={onClose}
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.'
}
title={t('settings.2fa.recovery.title')}
description={t('settings.2fa.recovery.description')}
hideCloseIcon
preventExternalClose
>
@@ -40,10 +41,10 @@ export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
</pre>
</CopyOnClick>
<Alert type={'danger'} className={'mt-3'}>
These codes will not be shown again.
{t('settings.2fa.recovery.alert')}
</Alert>
<Dialog.Footer>
<Button.Text onClick={onClose}>Done</Button.Text>
<Button.Text onClick={onClose}>{t('done')}</Button.Text>
</Dialog.Footer>
</Dialog>
);

View File

@@ -3,6 +3,7 @@
import { Actions, useStoreActions } from 'easy-peasy';
import QRCode from 'qrcode.react';
import { useContext, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import CopyOnClick from '@/components/elements/CopyOnClick';
@@ -31,6 +32,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
const [token, setToken] = useState<TwoFactorTokenData | null>(null);
const { clearAndAddHttpError } = useFlashKey('account:two-step');
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { t } = useTranslation();
const { close, setProps } = useContext(DialogWrapperContext);
@@ -75,12 +77,14 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
</div>
<CopyOnClick text={token?.secret}>
<p className={'font-mono text-sm text-zinc-100 text-center mt-2'}>
{token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'}
{token?.secret.match(/.{1,4}/g)!.join(' ') || t('loading')}
</p>
</CopyOnClick>
<p id={'totp-code-description'} className={'mt-6'}>
Scan the QR code above using an authenticator app, or enter the secret code above. Then, enter the
6-digit code it generates below.
<Trans i18nKey={'settings.2fa.setup.description'}>
Scan the QR code above using an authenticator app, or enter the secret code above. Then, enter the
6-digit code it generates below.
</Trans>
</p>
<Input.Text
aria-labelledby={'totp-code-description'}
@@ -95,7 +99,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
pattern={'\\d{6}'}
/>
<label htmlFor={'totp-password'} className={'block mt-3'}>
Account Password
{t('account_password')}
</label>
<Input.Text
variant={Input.Text.Variants.Loose}
@@ -120,7 +124,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => {
type={'submit'}
form={'enable-totp-form'}
>
Enable
{t('enable')}
</Button>
{/* </Tooltip> */}
</Dialog.Footer>

View File

@@ -1,6 +1,7 @@
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { Form, Formik, FormikHelpers } from 'formik';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import * as Yup from 'yup';
import Field from '@/components/elements/Field';
@@ -22,6 +23,7 @@ const schema = Yup.object().shape({
});
export default () => {
const { t } = useTranslation();
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
const updateEmail = useStoreActions((state: Actions<ApplicationStore>) => state.user.updateUserEmail);
@@ -64,7 +66,7 @@ export default () => {
id={'confirm_password'}
type={'password'}
name={'password'}
label={'Confirm Password'}
label={t('settings.password.confirm_password')}
/>
</div>
<div className={`mt-6`}>

View File

@@ -0,0 +1,83 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/elements/DropdownMenu';
import { Button } from '@/components/elements/button/index';
/**
* Các ngôn ngữ được hỗ trợ trong hệ thống
*/
const SUPPORTED_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'vi', label: 'Tiếng Việt' },
];
/**
* Component form cho phép người dùng thay đổi ngôn ngữ hiển thị của hệ thống
*/
const UpdateLanguageForm = () => {
const { t, i18n } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
// Tìm ngôn ngữ hiện tại để hiển thị trong dropdown
const currentLanguage = SUPPORTED_LANGUAGES.find((lang) => lang.value === i18n.language) || SUPPORTED_LANGUAGES[0];
/**
* Xử lý khi người dùng thay đổi ngôn ngữ
* @param value - Giá trị ngôn ngữ được chọn
*/
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
};
/**
* Xử lý khi người dùng lưu thay đổi ngôn ngữ
*/
const handleSubmit = () => {
//TODO: Lưu thay đổi ngôn ngữ
setIsSubmitting(true);
// Giả lập việc lưu thay đổi (sẽ được thay thế bằng API thực tế sau)
setTimeout(() => {
setIsSubmitting(false);
}, 500);
};
return (
<div className='mb-6'>
<p className='text-sm text-gray-400 mb-4'>{t('language.choose_language')}</p>
<DropdownMenu>
<DropdownMenuTrigger className='w-full bg-[#ffffff17] sm:w-auto flex items-center gap-2 text-white rounded-md p-2'>
<p className='text-sm'>{currentLanguage?.label || 'English'}</p>
<svg xmlns='http://www.w3.org/2000/svg' width='13' height='13' viewBox='0 0 13 13' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M3.39257 5.3429C3.48398 5.25161 3.60788 5.20033 3.73707 5.20033C3.86626 5.20033 3.99016 5.25161 4.08157 5.3429L6.49957 7.7609L8.91757 5.3429C8.9622 5.29501 9.01602 5.25659 9.07582 5.22995C9.13562 5.2033 9.20017 5.18897 9.26563 5.18782C9.33109 5.18667 9.39611 5.19871 9.45681 5.22322C9.51751 5.24774 9.57265 5.28424 9.61895 5.33053C9.66524 5.37682 9.70173 5.43196 9.72625 5.49267C9.75077 5.55337 9.76281 5.61839 9.76166 5.68384C9.7605 5.7493 9.74617 5.81385 9.71953 5.87365C9.69288 5.93345 9.65447 5.98727 9.60657 6.0319L6.84407 8.7944C6.75266 8.8857 6.62876 8.93698 6.49957 8.93698C6.37038 8.93698 6.24648 8.8857 6.15507 8.7944L3.39257 6.0319C3.30128 5.9405 3.25 5.81659 3.25 5.6874C3.25 5.55822 3.30128 5.43431 3.39257 5.3429Z'
fill='white'
fillOpacity='0.37'
/>
</svg>
</DropdownMenuTrigger>
<DropdownMenuContent>
{SUPPORTED_LANGUAGES.map((language) => (
<DropdownMenuItem key={language.value} onClick={() => handleLanguageChange(language.value)}>
{language.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className='flex justify-end mt-4'>
<Button className='w-full sm:w-auto' disabled={isSubmitting} onClick={handleSubmit}>
{t('common.save_changes')}
</Button>
</div>
</div>
);
};
export default UpdateLanguageForm;

View File

@@ -1,6 +1,7 @@
import { Actions, State, useStoreActions, useStoreState } from 'easy-peasy';
import { Form, Formik, FormikHelpers } from 'formik';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import * as Yup from 'yup';
import Field from '@/components/elements/Field';
@@ -18,19 +19,23 @@ interface Values {
confirmPassword: string;
}
const schema = Yup.object().shape({
current: Yup.string().min(1).required('You must provide your current account password.'),
password: Yup.string().min(8).required(),
confirmPassword: Yup.string().test(
'password',
'Password confirmation does not match the password you entered.',
function (value) {
return value === this.parent.password;
},
),
});
export default () => {
const { t } = useTranslation();
const schema = Yup.object().shape({
current: Yup.string().min(1).required(t('settings.password.validation.current_required')),
password: Yup.string()
.min(8, t('settings.password.validation.at_least_8_characters'))
.required(t('settings.password.validation.password_required')),
confirmPassword: Yup.string().test(
'password',
t('settings.password.validation.password_mismatch'),
function (value) {
return value === this.parent.password;
},
),
});
const user = useStoreState((state: State<ApplicationStore>) => state.user.data);
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@@ -49,7 +54,7 @@ export default () => {
addFlash({
key: 'account:password',
type: 'error',
title: 'Error',
title: t('error'),
message: httpErrorToHuman(error),
}),
)
@@ -71,17 +76,15 @@ export default () => {
id={'current_password'}
type={'password'}
name={'current'}
label={'Current Password'}
label={t('settings.password.current_password')}
/>
<div className={`mt-6`}>
<Field
id={'new_password'}
type={'password'}
name={'password'}
label={'New Password'}
description={
'Your new password should be at least 8 characters in length and unique to this website.'
}
label={t('settings.password.new_password')}
description={t('settings.password.requirements')}
/>
</div>
<div className={`mt-6`}>
@@ -89,11 +92,13 @@ export default () => {
id={'confirm_new_password'}
type={'password'}
name={'confirmPassword'}
label={'Confirm New Password'}
label={t('settings.password.confirm_password')}
/>
</div>
<div className={`mt-6`}>
<Button disabled={isSubmitting || !isValid}>Update Password</Button>
<Button disabled={isSubmitting || !isValid}>
{t('settings.password.update_password')}
</Button>
</div>
</Form>
</Fragment>

View File

@@ -1,6 +1,7 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { object, string } from 'yup';
import FlashMessageRender from '@/components/FlashMessageRender';
@@ -22,6 +23,7 @@ interface Values {
}
export default () => {
const { t } = useTranslation();
const [sshKey, setSshKey] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { mutate } = useSSHKeys();
@@ -56,8 +58,8 @@ export default () => {
onSubmit={submit}
initialValues={{ name: '', publicKey: '' }}
validationSchema={object().shape({
name: string().required('SSH Key Name is required'),
publicKey: string().required('Public Key is required'),
name: string().required(t('ssh_key.name_required')),
publicKey: string().required(t('ssh_key.public_key_required')),
})}
>
{({ isSubmitting }) => (
@@ -67,18 +69,18 @@ export default () => {
{/* SSH Key Name Field */}
<FormikFieldWrapper
label='SSH Key Name'
label={t('ssh_key.name')}
name='name'
description='A name to identify this SSH key.'
description={t('ssh_key.name_description')}
>
<Field name='name' as={Input} className='w-full' />
</FormikFieldWrapper>
{/* Public Key Field */}
<FormikFieldWrapper
label='Public Key'
label={t('ssh_key.public_key')}
name='publicKey'
description='Enter your public SSH key.'
description={t('ssh_key.public_key_description')}
>
<Field name='publicKey' as={Input} className='w-full' />
</FormikFieldWrapper>
@@ -86,7 +88,7 @@ export default () => {
{/* Submit Button below form fields */}
<div className='flex justify-end mt-6'>
<Button type='submit' disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create SSH Key'}
{isSubmitting ? t('ssh_key.creating') : t('ssh_key.create')}
</Button>
</div>
</Form>

View File

@@ -1,5 +1,6 @@
import ModalContext from '@/context/ModalContext';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/elements/button/index';
@@ -15,13 +16,14 @@ type Props = {
const ConfirmationModal: React.FC<Props> = ({ children, buttonText, onConfirmed }) => {
const { dismiss } = useContext(ModalContext);
const { t } = useTranslation();
return (
<>
<div className='flex flex-col w-full'>
<div className={`text-zinc-300`}>{children}</div>
<div className={`flex gap-4 items-center justify-end my-6`}>
<Button.Text onClick={() => dismiss()}>Cancel</Button.Text>
<Button.Text onClick={() => dismiss()}>{t('cancel')}</Button.Text>
<Button onClick={() => onConfirmed()}>{buttonText}</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Dialog as HDialog } from '@headlessui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import Spinner from '@/components/elements/Spinner';
@@ -80,6 +81,7 @@ const Modal: React.FC<ModalProps> = ({
const [_, setFooter] = useState<React.ReactNode>();
const [iconPosition, setIconPosition] = useState<IconPosition>('title');
const [down, setDown] = useState(false);
const { t } = useTranslation();
const onContainerClick = (down: boolean, e: React.MouseEvent<HTMLDivElement>): void => {
if (e.target instanceof HTMLElement && container.current?.isSameNode(e.target)) {
@@ -165,7 +167,7 @@ const Modal: React.FC<ModalProps> = ({
{closeButton && (
<div className={`my-6 sm:flex items-center justify-end`}>
<Button onClick={onDismissed} className={`min-w-full`}>
<div>Close</div>
<div>{t('close')}</div>
</Button>
</div>
)}

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import MainPage from '@/components/elements/MainPage';
@@ -11,11 +12,13 @@ export interface PageContentBlockProps {
}
const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey, className, children }) => {
const { t } = useTranslation();
useEffect(() => {
if (title) {
document.title = title + ' | Pyrodactyl';
document.title = title + t('server_titles.site_name_suffix');
}
}, [title]);
}, [title, t]);
return (
<>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ServerError } from '@/components/elements/ScreenBlock';
@@ -17,11 +18,13 @@ function PermissionRoute({ children, permission }: Props): JSX.Element {
const can = usePermissions(permission);
const { t } = useTranslation();
if (can.filter((p) => p).length > 0) {
return <>{children}</>;
}
return <ServerError title='Access Denied' message='You do not have permission to access this page.' />;
return <ServerError title={t('errors.access_denied_title')} message={t('errors.access_denied_message')} />;
}
export default PermissionRoute;

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
const ScreenBlock = ({ title, message }) => {
@@ -27,18 +28,19 @@ const ServerError = ({ title, message }) => {
};
const NotFound = () => {
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]'>Page Not Found</h1>
<p className=''>
We couldn&apos;t find the page you&apos;re looking for. You may have lost access, or the page
may have been removed. Here are some helpful links instead:
</p>
<h1 className='text-[32px] font-extrabold leading-[98%] tracking-[-0.11rem]'>
{t('errors.page_not_found_title')}
</h1>
<p className=''>{t('errors.page_not_found_message')}</p>
<div className='flex flex-col gap-2'>
<Link to={'/'} className='text-brand'>
Your Servers
{t('server_titles.your_servers')}
</Link>
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/elements/button/index';
import { Dialog, RenderDialogProps } from './';
@@ -8,13 +10,16 @@ type ConfirmationProps = Omit<RenderDialogProps, 'description' | 'children'> & {
onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
};
export default ({ confirm = 'Okay', children, onConfirmed, ...props }: ConfirmationProps) => {
export default ({ confirm, children, onConfirmed, ...props }: ConfirmationProps) => {
const { t } = useTranslation();
const confirmText = confirm || t('ok');
return (
<Dialog {...props} description={typeof children === 'string' ? children : undefined}>
{typeof children !== 'string' && children}
<Dialog.Footer>
<Button.Text onClick={props.onClose}>Cancel</Button.Text>
<Button.Danger onClick={onConfirmed}>{confirm}</Button.Danger>
<Button.Text onClick={props.onClose}>{t('cancel')}</Button.Text>
<Button.Danger onClick={onConfirmed}>{confirmText}</Button.Danger>
</Dialog.Footer>
</Dialog>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink } from 'react-router-dom';
import FlashMessageRender from '@/components/FlashMessageRender';
@@ -25,6 +26,8 @@ function ScheduleContainer() {
const schedules = ServerContext.useStoreState((state) => state.schedules.data);
const setSchedules = ServerContext.useStoreActions((actions) => actions.schedules.setSchedules);
const { t } = useTranslation();
useEffect(() => {
clearFlashes('schedules');
@@ -40,7 +43,7 @@ function ScheduleContainer() {
return (
<ServerContentBlock title={'Schedules'}>
<FlashMessageRender byKey={'schedules'} />
<MainPageHeader title={'Schedules'}>
<MainPageHeader title={t('server_titles.schedules')}>
<Can action={'schedule.create'}>
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)} />
<button
@@ -51,7 +54,7 @@ function ScheduleContainer() {
className='rounded-full border-[1px] border-[#ffffff12] px-8 py-3 text-sm font-bold shadow-md'
onClick={() => setVisible(true)}
>
New Schedule
{t('server_schedules.new_schedule_button')}
</button>
</Can>
</MainPageHeader>

View File

@@ -1,5 +1,6 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { Button } from '@/components/elements/button/index';
@@ -15,6 +16,7 @@ export default () => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const [modalVisible, setModalVisible] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { t } = useTranslation();
const reinstall = () => {
clearFlashes('settings');
@@ -23,7 +25,7 @@ export default () => {
addFlash({
key: 'settings',
type: 'success',
message: 'Your server has begun the reinstallation process.',
message: t('server.settings.reinstall.toast.reinstall_started'),
});
})
.catch((error) => {
@@ -39,28 +41,29 @@ export default () => {
}, []);
return (
<TitledGreyBox title={'Reinstall Server'}>
<TitledGreyBox title={t('server.settings.reinstall.title')}>
<Dialog.Confirm
open={modalVisible}
title={'Confirm server reinstallation'}
confirm={'Yes, reinstall server'}
title={t('server.settings.reinstall.confirm.title')}
confirm={t('server.settings.reinstall.confirm.confirm')}
onClose={() => setModalVisible(false)}
onConfirmed={reinstall}
>
Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue?
{t('server.settings.reinstall.confirm.description')}
</Dialog.Confirm>
<p className={`text-sm`}>
Reinstalling your server will stop it, and then re-run the installation script that initially set it
up.&nbsp;
<strong className={`font-medium`}>
Some files may be deleted or modified during this process, please back up your data before
continuing.
</strong>
<Trans i18nKey={'server.settings.reinstall.description'}>
Reinstalling your server will stop it, and then re-run the installation script that initially set it
up.&nbsp;
<strong className={`font-medium`}>
Some files may be deleted or modified during this process, please back up your data before
continuing.
</strong>
</Trans>
</p>
<div className={`mt-6 text-right`}>
<Button.Danger variant={Button.Variants.Secondary} onClick={() => setModalVisible(true)}>
Reinstall Server
{t('server.settings.reinstall.button')}
</Button.Danger>
</div>
</TitledGreyBox>

View File

@@ -1,5 +1,6 @@
import { Actions, useStoreActions } from 'easy-peasy';
import { Form, Formik } from 'formik';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { object, string } from 'yup';
@@ -19,13 +20,19 @@ interface Values {
}
const RenameServerBox = () => {
const { t } = useTranslation();
return (
<TitledGreyBox title={'Server Details'}>
<TitledGreyBox title={t('server.settings.rename.title')}>
<Form className='flex flex-col gap-4'>
<Field id={'name'} name={'name'} label={'Server Name'} type={'text'} />
<Field id={'description'} name={'description'} label={'Server Description'} type={'text'} />
<Field id={'name'} name={'name'} label={t('server.settings.rename.server_name')} type={'text'} />
<Field
id={'description'}
name={'description'}
label={t('server.settings.rename.server_description')}
type={'text'}
/>
<div className={`mt-6 text-right`}>
<Button type={'submit'}>Save</Button>
<Button type={'submit'}>{t('save')}</Button>
</div>
</Form>
</TitledGreyBox>
@@ -36,17 +43,18 @@ export default () => {
const server = ServerContext.useStoreState((state) => state.server.data!);
const setServer = ServerContext.useStoreActions((actions) => actions.server.setServer);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { t } = useTranslation();
const submit = ({ name, description }: Values) => {
clearFlashes('settings');
toast('Updating server details...');
toast(t('server.settings.rename.toast.updating'));
renameServer(server.uuid, name, description)
.then(() => setServer({ ...server, name, description }))
.catch((error) => {
console.error(error);
addError({ key: 'settings', message: httpErrorToHuman(error) });
})
.then(() => toast.success('Server details updated!'));
.then(() => toast.success(t('server.settings.rename.toast.updated')));
};
return (

View File

@@ -1,5 +1,6 @@
import { useStoreState } from 'easy-peasy';
import isEqual from 'react-fast-compare';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
@@ -23,11 +24,12 @@ export default () => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const node = ServerContext.useStoreState((state) => state.server.data!.node);
const sftp = ServerContext.useStoreState((state) => state.server.data!.sftpDetails, isEqual);
const { t } = useTranslation();
return (
<ServerContentBlock title={'Settings'}>
<ServerContentBlock title={t('server.settings.header')}>
<FlashMessageRender byKey={'settings'} />
<MainPageHeader title={'Settings'} />
<MainPageHeader title={t('server.settings.header')} />
<Can action={'settings.rename'}>
<div className={`mb-6 md:mb-10`}>
<RenameServerBox />
@@ -38,22 +40,22 @@ export default () => {
<Can action={'settings.reinstall'}>
<ReinstallServerBox />
</Can>
<TitledGreyBox title={'Debug Information'}>
<TitledGreyBox title={t('server.settings.debug_information')}>
<div className={`flex items-center justify-between text-sm`}>
<p>Node</p>
<p>{t('server.settings.node')}</p>
<code className={`font-mono bg-zinc-900 rounded py-1 px-2`}>{node}</code>
</div>
<CopyOnClick text={uuid}>
<div className={`flex items-center justify-between mt-2 text-sm`}>
<p>Server ID</p>
<p>{t('server.settings.server_id')}</p>
<code className={`font-mono bg-zinc-900 rounded py-1 px-2`}>{uuid}</code>
</div>
</CopyOnClick>
</TitledGreyBox>
<Can action={'file.sftp'}>
<TitledGreyBox title={'SFTP Details'} className={`mb-6 md:mb-10`}>
<TitledGreyBox title={t('server.settings.sftp_details')} className={`mb-6 md:mb-10`}>
<div className={`flex items-center justify-between text-sm`}>
<Label>Server Address</Label>
<Label>{t('server.settings.server_address')}</Label>
<CopyOnClick text={`sftp://${ip(sftp.ip)}:${sftp.port}`}>
<code
className={`font-mono bg-zinc-900 rounded py-1 px-2`}
@@ -61,7 +63,7 @@ export default () => {
</CopyOnClick>
</div>
<div className={`mt-2 flex items-center justify-between text-sm`}>
<Label>Username</Label>
<Label>{t('server.settings.username')}</Label>
<CopyOnClick text={`${username}.${id}`}>
<code className={`font-mono bg-zinc-900 rounded py-1 px-2`}>{`${username}.${id}`}</code>
</CopyOnClick>
@@ -69,14 +71,14 @@ export default () => {
<div className={`mt-6 flex items-center`}>
<div className={`flex-1`}>
<div className={`border-l-4 border-brand p-3`}>
<p className={`text-xs text-zinc-200`}>
Your SFTP password is the same as the password you use to access this panel.
</p>
<p className={`text-xs text-zinc-200`}>{t('server.settings.sftp_password_note')}</p>
</div>
</div>
<div className={`ml-4`}>
<a href={`sftp://${username}.${id}@${ip(sftp.ip)}:${sftp.port}`}>
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>
<Button.Text variant={Button.Variants.Secondary}>
{t('server.settings.launch_sftp')}
</Button.Text>
</a>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import isEqual from 'react-fast-compare';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import Button from '@/components/elements/ButtonV2';
@@ -74,6 +75,7 @@ const hidden_nest_prefix = '!';
const blank_egg_prefix = '@';
const SoftwareContainer = () => {
const [t] = useTranslation();
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const [nests, setNests] = useState<Nest[]>();
const eggs = nests?.reduce(
@@ -296,21 +298,17 @@ const SoftwareContainer = () => {
return (
<ServerContentBlock title='Software'>
<MainPageHeader direction='column' title='Software'>
<h2 className='text-sm'>
Welcome to the software management page. Here you can change the game or software that is running on
your server.
</h2>
<h2 className='text-sm'>Welcome</h2>
</MainPageHeader>
<Dialog.Confirm
open={modalVisible}
title={'Confirm server reinstallation'}
confirm={'Yes, reinstall server'}
title='Confirm Reinstall'
confirm='Yes Reinstall'
onClose={() => setModalVisible(false)}
onConfirmed={() => handleEggSelect()}
>
Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue?
{t('server.reinstallWarning')}
</Dialog.Confirm>
{!visible && (

View File

@@ -1,6 +1,7 @@
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { For } from 'million/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can';
@@ -27,6 +28,8 @@ export default () => {
const getPermissions = useStoreActions((actions: Actions<ApplicationStore>) => actions.permissions.getPermissions);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { t } = useTranslation();
useEffect(() => {
clearFlashes('users');
getServerSubusers(uuid)
@@ -50,7 +53,9 @@ export default () => {
if (!subusers.length && (loading || !Object.keys(permissions).length)) {
return (
<ServerContentBlock title={'Users'}>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>Users</h1>
<h1 className='text-[52px] font-extrabold leading-[98%] tracking-[-0.14rem]'>
{t('server_titles.users')}
</h1>
</ServerContentBlock>
);
}
@@ -58,15 +63,13 @@ export default () => {
return (
<ServerContentBlock title={'Users'}>
<FlashMessageRender byKey={'users'} />
<MainPageHeader title={'Users'}>
<MainPageHeader title={t('server_titles.users')}>
<Can action={'user.create'}>
<AddSubuserButton />
</Can>
</MainPageHeader>
{!subusers.length ? (
<p className={`text-center text-sm text-zinc-300`}>
Your server does not have any additional users. Add others to help you manage your server.
</p>
<p className={`text-center text-sm text-zinc-300`}>{t('server_users.no_additional_users')}</p>
) : (
<PageListContainer data-pyro-users-container-users>
<For each={subusers} memo>

54
resources/scripts/i18n.ts Normal file
View File

@@ -0,0 +1,54 @@
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
/**
* Khởi tạo cấu hình i18n cho ứng dụng
* - Sử dụng Backend để tải file ngôn ngữ từ thư mục public/locales
* - Sử dụng LanguageDetector để tự động phát hiện ngôn ngữ từ trình duyệt
* - Khởi tạo với ngôn ngữ mặc định là tiếng Anh
*/
i18n
// Tải ngôn ngữ từ thư mục public/locales/{lng}/translation.json
.use(Backend)
// Phát hiện ngôn ngữ tự động từ trình duyệt
.use(LanguageDetector)
// Tích hợp với react-i18next
.use(initReactI18next)
// Khởi tạo i18next
.init({
// Ngôn ngữ mặc định
fallbackLng: 'en',
// Ngôn ngữ được hỗ trợ
supportedLngs: ['en', 'vi'],
// Không sử dụng dấu chấm để phân tách khóa
debug: false,
// Cấu hình tải các namespace
ns: ['translation'],
defaultNS: 'translation',
// Cấu hình backend
backend: {
// Đường dẫn tới file ngôn ngữ
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
// Cấu hình phát hiện ngôn ngữ
detection: {
// Thứ tự phát hiện ngôn ngữ: localStorage, cookie, navigator
order: ['localStorage', 'cookie', 'navigator'],
// Lưu ngôn ngữ vào localStorage và cookie
caches: ['localStorage', 'cookie'],
},
// Cấu hình nội suy
interpolation: {
// React đã xử lý XSS nên không cần escape giá trị
escapeValue: false,
},
// Cấu hình cho react-i18next
react: {
// Đợi tải ngôn ngữ xong mới hiển thị nội dung
useSuspense: true,
},
});
export default i18n;

View File

@@ -1,11 +1,19 @@
import { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import App from '@/components/App';
// Import cấu hình i18n (phải được import trước App)
import './i18n';
const container = document.getElementById('app');
if (container) {
const root = createRoot(container);
root.render(<App />);
root.render(
<Suspense fallback='Loading...'>
<App />
</Suspense>,
);
} else {
console.error('Failed to find the root element');
}

3
storage/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.json
*.json.gz
index