mirror of
https://github.com/pyrohost/pyrodactyl.git
synced 2026-04-06 04:01:58 +02:00
feat: implement i18n support and update UI components for localization
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -45,3 +45,7 @@ nix/docker/maria/mariadb_data/
|
||||
nix/mariadb/
|
||||
wings/
|
||||
mariadb_data/
|
||||
|
||||
docker-compose.yml
|
||||
srv
|
||||
.cursor
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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't find the page you'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
<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.
|
||||
<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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
54
resources/scripts/i18n.ts
Normal 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;
|
||||
@@ -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
3
storage/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.json
|
||||
*.json.gz
|
||||
index
|
||||
Reference in New Issue
Block a user