Files
PnvPanel/frontend/src/shared/lib/i18n.ts
T
Leonid Pershin b980dc6cef
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s
Add IsAvailable property to Inbound and update related logic
- Introduced a new boolean property, `IsAvailable`, to the `Inbound` entity to track the availability status of inbounds based on synchronization results.
- Updated the `SyncNodeCommandHandler` to mark inbounds as unavailable if they are not present in the latest synchronization but have existing configurations, preventing their deletion.
- Enhanced the `MarkUnavailable` method to set both `IsAvailable` and `IsPublished` to false, reflecting the new status accurately.
- Modified the frontend components to display the availability status of inbounds, ensuring users are informed of their current state.
- Updated tests to cover the new behavior regarding inbound availability and its impact on revocation processes.
2026-07-19 00:13:30 +03:00

907 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
const resources = {
ru: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service портал для VPN-конфигураций',
theme: 'Тема',
language: 'Язык',
light: 'Светлая',
dark: 'Тёмная',
system: 'Системная',
unlimited: 'Без лимита',
auth: {
loginTitle: 'Вход',
registerTitle: 'Регистрация',
userName: 'Имя пользователя',
password: 'Пароль',
submitLogin: 'Войти',
submitRegister: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?',
haveAccount: 'Уже есть аккаунт?',
goRegister: 'Зарегистрироваться',
goLogin: 'Войти',
loginViaTelegram: 'Войти через Telegram',
invalidCredentials: 'Неверное имя пользователя или пароль.',
duplicateUserName: 'Пользователь с таким именем уже существует.',
genericError: 'Что-то пошло не так. Попробуйте ещё раз.',
userNameHint: 'Латиница, цифры, «_», «.», «-», от 3 до 32 символов.',
passwordHint: 'Не менее 8 символов, минимум одна заглавная буква и одна цифра.',
or: 'или',
telegramBotNotConfigured: 'Telegram-бот не настроен администратором.',
telegramQrHint:
'Перейдите по ссылке или отсканируйте QR-код — откроется бот в Telegram. Если открываете его впервые, нажмите «Старт».',
waitingForConfirmation: 'Ожидание подтверждения в Telegram…',
telegramLoginRejected: 'Вход отклонён в Telegram.',
telegramLoginExpired: 'Время ожидания истекло, попробуйте снова.',
},
telegramLinkWarning: {
message: 'Вы не привязали Telegram — без этого вы не будете получать уведомления и не сможете самостоятельно восстановить пароль.',
action: 'Привязать Telegram',
dismiss: 'Скрыть',
},
nav: {
dashboard: 'Мои конфиги',
instructions: 'Инструкции',
news: 'Новости',
support: 'Поддержка',
settings: 'Настройки',
admin: 'Админка',
logout: 'Выйти',
toggleMenu: 'Меню',
},
activation: {
title: 'Аккаунт не активирован',
description:
'Чтобы создавать конфиги, дождитесь активации администратором. Можно оставить комментарий к заявке.',
commentLabel: 'Комментарий (необязательно)',
submit: 'Запросить активацию',
pending: 'Заявка на активацию отправлена, ожидайте решения администратора.',
alreadyPending: 'У вас уже есть необработанная заявка на активацию.',
retry: 'Повторить',
},
configs: {
title: 'Мои конфиги',
quota: 'Использовано {{used}} из {{max}}',
quotaUnlimited: 'Использовано {{used}}, без лимита',
empty: 'У вас пока нет конфигов. Создайте первый.',
create: 'Создать конфиг',
selectLocation: 'Выберите локацию',
location: 'Локация',
label: 'Метка (необязательно)',
noInboundsNotice: 'Пока нет доступных локаций для создания конфига. Обратитесь к администратору — необходимо, чтобы он добавил сервер.',
created: 'Конфиг создан.',
quotaExceeded: 'Достигнут лимит конфигов для вашей роли.',
showLink: 'Ссылка / QR',
rotate: 'Перевыпустить',
rotated: 'Конфиг перевыпущен.',
revoked: 'Конфиг отозван.',
confirmRevoke: 'Отозвать этот конфиг? Действие необратимо.',
copied: 'Скопировано.',
copyLink: 'Скопировать ссылку',
loadingLink: 'Загрузка ссылки…',
subscriptionLink: 'Ссылка-подписка (для клиента):',
panelEmail: 'Email в панели: {{email}}',
aggregatedSubscription: 'Общая подписка',
aggregatedSubscriptionHint: 'Одна ссылка/QR со всеми активными конфигами — удобно добавить один раз в клиент.',
status: {
Active: 'Активен',
Disabled: 'Отключён',
Expired: 'Истёк',
LimitReached: 'Лимит исчерпан',
Revoked: 'Отозван',
},
},
instructions: {
title: 'Инструкции по подключению',
appsTitle: 'Приложения',
noApps: 'Каталог приложений пока пуст.',
recommended: 'Рекомендуем',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
news: {
title: 'Новости',
empty: 'Пока нет новостей.',
},
support: {
title: 'Поддержка',
empty: 'У вас пока нет обращений.',
reportBug: 'Сообщить об ошибке',
requestRole: 'Запросить роль',
submit: 'Отправить',
messageLabel: 'Опишите проблему или предложение',
attachmentsLabel: 'Скриншоты (необязательно, до 5)',
filesSelected: '{{count}} файл(ов) выбрано',
existingRole: 'Существующая роль',
newRole: 'Новая роль',
selectRole: 'Выберите роль',
roleOption: '{{name}} — конфигов: {{configs}}, IP: {{ip}}',
newRoleName: 'Название роли',
newRoleMaxConfigs: 'Количество конфигов (-1 — без лимита)',
newRoleMaxIpLimit: 'Количество IP (-1 — без лимита)',
pricingTitle: 'Ориентировочная стоимость',
pricingQuarter: '3 месяца: {{price}}',
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
reply: 'Ответить',
replyPlaceholder: 'Написать комментарий…',
reopen: 'Переоткрыть',
reopened: 'Тикет переоткрыт.',
lastActivity: 'Последняя активность: {{date}}',
requestedExistingRole: 'Запрошена роль: {{role}}',
requestedNewRole: 'Запрошена новая роль «{{name}}» (конфигов: {{configs}}, IP: {{ip}})',
type: {
BugReport: 'Ошибка/предложение',
RoleRequest: 'Заявка на роль',
},
status: {
Open: 'Открыт',
Resolved: 'Решён',
Closed: 'Закрыт',
},
},
settings: {
changePassword: 'Сменить пароль',
currentPassword: 'Текущий пароль',
currentPasswordRequired: 'Введите текущий пароль.',
currentPasswordInvalid: 'Неверный текущий пароль.',
newPassword: 'Новый пароль',
passwordChanged: 'Пароль изменён.',
changeUserName: 'Сменить логин',
changeUserNameHint: 'Текущий логин: {{current}}.',
newUserName: 'Новый логин',
userNameChanged: 'Логин изменён.',
telegramHint: 'Привязка Telegram нужна для входа без пароля и восстановления доступа.',
telegramLinkedStatus: 'Привязан',
link: 'Привязать Telegram',
unlink: 'Отвязать',
confirmUnlink: 'Отвязать Telegram от аккаунта?',
telegramLinked: 'Telegram привязан.',
telegramUnlinked: 'Telegram отвязан.',
waitingForLink: 'Ожидание подтверждения в Telegram…',
deleteAccount: 'Удалить аккаунт',
deleteAccountHint: 'Отзовёт все конфиги и безвозвратно удалит аккаунт.',
confirmDelete: 'Вы уверены? Это действие необратимо.',
confirmDeleteYes: 'Да, удалить',
cancel: 'Отмена',
},
admin: {
prev: 'Назад',
next: 'Вперёд',
tabs: {
overview: 'Обзор',
activation: 'Запросы на активацию',
users: 'Пользователи',
configs: 'Все конфиги',
roles: 'Роли',
pricing: 'Цены',
nodes: 'Ноды',
apps: 'Приложения',
instructions: 'Инструкции',
news: 'Новости',
support: 'Тикеты',
audit: 'Аудит',
maintenance: 'Обслуживание',
},
users: {
searchPlaceholder: 'Поиск по имени пользователя',
userName: 'Имя пользователя',
role: 'Роль',
statusLabel: 'Статус',
manage: 'Управление',
empty: 'Пользователи не найдены.',
total: 'Всего: {{count}}',
status: {
blocked: 'Заблокирован',
active: 'Активен',
pending: 'Не активирован',
},
block: 'Заблокировать',
unblock: 'Разблокировать',
confirmBlock: 'Заблокировать пользователя? Все его конфиги будут отключены.',
blocked: 'Пользователь заблокирован.',
unblocked: 'Пользователь разблокирован.',
roleChanged: 'Роль изменена.',
resetPassword: 'Сбросить пароль',
reset: 'Сбросить',
passwordReset: 'Пароль сброшен.',
configs: 'Конфиги',
delete: 'Удалить пользователя',
confirmDelete: 'Удалить пользователя? Все его конфиги будут отозваны в 3x-ui, действие необратимо.',
deleted: 'Пользователь удалён.',
},
configs: {
searchPlaceholder: 'Поиск по email в панели или метке',
owner: 'Владелец',
node: 'Нода',
location: 'Локация',
protocol: 'Протокол',
traffic: 'Трафик',
statusLabel: 'Статус',
allStatuses: 'Все статусы',
created: 'Создан',
empty: 'Конфиги не найдены.',
total: 'Всего: {{count}}',
},
activation: {
empty: 'Нет ожидающих запросов на активацию.',
approved: 'Пользователь активирован.',
rejected: 'Запрос отклонён.',
approve: 'Активировать',
reject: 'Отклонить',
},
roles: {
create: 'Создать роль',
name: 'Название',
maxConfigs: 'Квота конфигов',
maxConfigsHint: '1 = без лимита.',
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
totalPerQuarter: 'Итого / 3 мес',
totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
system: 'системная',
edit: 'Изменить',
save: 'Сохранить',
delete: 'Удалить',
confirmDelete: 'Удалить роль? Это действие необратимо.',
created: 'Роль создана.',
updated: 'Квота обновлена.',
deleted: 'Роль удалена.',
},
pricing: {
title: 'Справочная цена конфига',
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
pricePerConfigPerHalfYear: 'Цена за конфиг в месяц / оплата раз в полгода',
pricePerConfigPerHalfYearHint: 'Может быть ниже квартальной (скидка за оплату на полгода вперёд), но итог за полгода не должен быть дешевле итога за квартал.',
halfYearCheaperThanQuarter: 'Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.',
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
pricePerConfigPerYearHint: 'Может быть ниже полугодовой (скидка за годовую оплату), но итог за год не должен быть дешевле итога за полгода.',
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
},
nodes: {
create: 'Добавить ноду',
name: 'Название',
baseAddress: 'Адрес панели',
username: 'Логин',
password: 'Пароль',
location: 'Локация',
empty: 'Ноды не добавлены.',
created: 'Нода добавлена.',
updated: 'Нода обновлена.',
deleted: 'Нода удалена.',
confirmDelete: 'Удалить ноду? Существующие конфиги на ней перестанут синхронизироваться.',
edit: 'Изменить',
delete: 'Удалить',
probe: 'Проверить',
sync: 'Синхронизировать',
probeSuccess: 'Нода доступна.',
probeFailure: 'Нода недоступна: {{message}}',
syncSuccess: 'Синхронизировано инбаундов: {{count}}',
status: {
Unknown: 'Неизвестно',
Online: 'Онлайн',
Offline: 'Офлайн',
},
enabled: 'Включена',
disabled: 'Отключена',
inbounds: 'Инбаунды',
noInbounds: 'Инбаунды не найдены — нажмите «Синхронизировать».',
publish: 'Публикация',
published: 'Опубликован',
unpublished: 'Не опубликован',
unavailable: 'Недоступен на панели',
publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя',
allowedRoles: 'Доступно ролям',
isPublishedLabel: 'Опубликовать инбаунд',
optional: 'необязательно',
},
apps: {
create: 'Добавить приложение',
name: 'Название',
downloadUrl: 'Ссылка на скачивание',
os: 'ОС',
description: 'Описание',
iconUrl: 'Ссылка на иконку',
sortOrder: 'Порядок',
enabled: 'Включено',
disabled: 'отключено',
recommended: 'Рекомендовано',
empty: 'Каталог приложений пуст.',
created: 'Приложение добавлено.',
updated: 'Приложение обновлено.',
deleted: 'Приложение удалено.',
confirmDelete: 'Удалить приложение из каталога?',
},
instructions: {
introTitle: 'Основная инструкция',
introHint: 'Показывается над вкладками на странице «Инструкции» (Markdown).',
introSaved: 'Инструкция сохранена.',
tabsTitle: 'Дополнительные вкладки',
tabsEmpty: 'Дополнительных вкладок пока нет.',
createTab: 'Добавить вкладку',
tabTitle: 'Название вкладки',
tabBody: 'Текст (Markdown)',
tabCreated: 'Вкладка добавлена.',
tabUpdated: 'Вкладка обновлена.',
tabDeleted: 'Вкладка удалена.',
confirmDeleteTab: 'Удалить вкладку инструкций?',
},
news: {
create: 'Добавить новость',
title: 'Заголовок',
body: 'Текст (Markdown)',
preview: 'Предпросмотр',
empty: 'Новостей пока нет.',
created: 'Новость опубликована.',
updated: 'Новость обновлена.',
deleted: 'Новость удалена.',
confirmDelete: 'Удалить новость?',
},
support: {
empty: 'Обращений пока нет.',
resolve: 'Решено',
resolved: 'Тикет отмечен как решённый.',
close: 'Закрыть',
closed: 'Тикет закрыт.',
approve: 'Одобрить',
approved: 'Заявка одобрена, роль выдана.',
reject: 'Отклонить',
rejected: 'Заявка отклонена.',
},
audit: {
time: 'Время',
action: 'Действие',
target: 'Объект',
source: 'Источник',
empty: 'Журнал аудита пуст.',
},
maintenance: {
closedTickets: {
title: 'Закрытые обращения',
description: 'Удалить все закрытые обращения вместе с перепиской и вложениями (файлами). Действие необратимо.',
action: 'Удалить закрытые обращения',
confirm: 'Удалить все закрытые обращения и файлы в них? Действие необратимо.',
deleted: 'Удалено обращений: {{count}}.',
},
audit: {
title: 'Журнал аудита',
description: 'Удалить записи журнала аудита старше указанного числа дней. Действие необратимо.',
olderThan: 'Старше',
days: 'дней',
action: 'Удалить старые записи',
confirm: 'Удалить записи аудита старше {{days}} дней? Действие необратимо.',
deleted: 'Удалено записей аудита: {{count}}.',
},
apps: {
title: 'Отключённые приложения',
description: 'Удалить все отключённые приложения из каталога. Действие необратимо.',
action: 'Удалить отключённые приложения',
confirm: 'Удалить все отключённые приложения? Действие необратимо.',
deleted: 'Удалено приложений: {{count}}.',
},
factoryReset: {
dangerZone: 'Опасная зона',
title: 'Полный сброс панели',
description: 'Вернуть панель в состояние только что развёрнутой — удалить всех пользователей, конфиги, ноды и все данные. Ваш текущий аккаунт сохранится.',
action: 'Сбросить панель',
dialogTitle: 'Полный сброс панели',
warningIntro: 'Будет удалено безвозвратно:',
warningItems: [
'Все пользователи, кроме вашего текущего аккаунта',
'Все VPN-конфиги (сначала будут отозваны на нодах 3x-ui)',
'Все ноды и инбаунды — регистрация серверов 3x-ui и сохранённые креды',
'Все обращения в поддержку вместе с перепиской и вложениями',
'Все новости',
'Весь журнал аудита',
'Все роли, кроме системных admin/user',
'Каталог приложений (будет пересеян из стартового набора)',
],
irreversible: 'Действие необратимо и не может быть отменено.',
confirmPhrase: 'СБРОС',
confirmInputLabel: 'Введите «{{phrase}}» для подтверждения',
confirmButton: 'Сбросить панель безвозвратно',
done: 'Панель сброшена до начального состояния.',
},
},
stats: {
totalUsers: 'Всего пользователей',
activatedUsers: 'Активировано',
pendingActivationRequests: 'Ожидают активации',
totalNodes: 'Всего нод',
onlineNodes: 'Нод онлайн',
totalConfigs: 'Всего конфигов',
activeConfigs: 'Активных конфигов',
totalTraffic: 'Суммарный трафик',
},
},
},
},
en: {
translation: {
appName: 'PnvPanel',
tagline: 'Self-service portal for VPN configurations',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
unlimited: 'Unlimited',
auth: {
loginTitle: 'Log in',
registerTitle: 'Register',
userName: 'Username',
password: 'Password',
submitLogin: 'Log in',
submitRegister: 'Register',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
goRegister: 'Register',
goLogin: 'Log in',
loginViaTelegram: 'Log in via Telegram',
invalidCredentials: 'Invalid username or password.',
duplicateUserName: 'A user with this name already exists.',
genericError: 'Something went wrong. Please try again.',
userNameHint: 'Latin letters, digits, "_", ".", "-", 3 to 32 characters.',
passwordHint: 'At least 8 characters, with one uppercase letter and one digit.',
or: 'or',
telegramBotNotConfigured: 'The Telegram bot has not been configured by the administrator.',
telegramQrHint:
'Follow the link or scan the QR code — it opens the bot in Telegram. If this is your first time, tap "Start".',
waitingForConfirmation: 'Waiting for confirmation in Telegram…',
telegramLoginRejected: 'Login was rejected in Telegram.',
telegramLoginExpired: 'The request expired, please try again.',
},
telegramLinkWarning: {
message: "You haven't linked Telegram — without it you won't receive notifications and won't be able to recover your password yourself.",
action: 'Link Telegram',
dismiss: 'Dismiss',
},
nav: {
dashboard: 'My configs',
instructions: 'Instructions',
news: 'News',
support: 'Support',
settings: 'Settings',
admin: 'Admin',
logout: 'Log out',
toggleMenu: 'Menu',
},
activation: {
title: 'Account not activated',
description:
'Wait for an administrator to activate your account before creating configs. You can leave a comment with your request.',
commentLabel: 'Comment (optional)',
submit: 'Request activation',
pending: 'Activation request sent, waiting for administrator review.',
alreadyPending: 'You already have a pending activation request.',
retry: 'Retry',
},
configs: {
title: 'My configs',
quota: 'Used {{used}} of {{max}}',
quotaUnlimited: 'Used {{used}}, unlimited',
empty: "You don't have any configs yet. Create your first one.",
create: 'Create config',
selectLocation: 'Select location',
location: 'Location',
label: 'Label (optional)',
noInboundsNotice: 'No locations are available for creating a config yet. Please contact the administrator — a server needs to be added.',
created: 'Config created.',
quotaExceeded: 'Config quota reached for your role.',
showLink: 'Link / QR',
rotate: 'Rotate',
rotated: 'Config rotated.',
revoked: 'Config revoked.',
confirmRevoke: 'Revoke this config? This cannot be undone.',
copied: 'Copied.',
copyLink: 'Copy link',
loadingLink: 'Loading link…',
subscriptionLink: 'Subscription link (for the client app):',
panelEmail: 'Panel email: {{email}}',
aggregatedSubscription: 'Aggregated subscription',
aggregatedSubscriptionHint: 'One link/QR with all active configs — add it once to your client.',
status: {
Active: 'Active',
Disabled: 'Disabled',
Expired: 'Expired',
LimitReached: 'Limit reached',
Revoked: 'Revoked',
},
},
instructions: {
title: 'Connection instructions',
appsTitle: 'Apps',
noApps: 'The app catalog is empty right now.',
recommended: 'Recommended',
os: {
IOS: 'iOS',
Android: 'Android',
Windows: 'Windows',
MacOS: 'macOS',
Linux: 'Linux',
},
},
news: {
title: 'News',
empty: 'No news yet.',
},
support: {
title: 'Support',
empty: 'You have no tickets yet.',
reportBug: 'Report a bug',
requestRole: 'Request a role',
submit: 'Submit',
messageLabel: 'Describe the issue or suggestion',
attachmentsLabel: 'Screenshots (optional, up to 5)',
filesSelected: '{{count}} file(s) selected',
existingRole: 'Existing role',
newRole: 'New role',
selectRole: 'Select a role',
roleOption: '{{name}} — configs: {{configs}}, IP: {{ip}}',
newRoleName: 'Role name',
newRoleMaxConfigs: 'Max configs (-1 = unlimited)',
newRoleMaxIpLimit: 'Max IPs (-1 = unlimited)',
pricingTitle: 'Estimated cost',
pricingQuarter: '3 months: {{price}}',
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
reply: 'Reply',
replyPlaceholder: 'Write a comment…',
reopen: 'Reopen',
reopened: 'Ticket reopened.',
lastActivity: 'Last activity: {{date}}',
requestedExistingRole: 'Requested role: {{role}}',
requestedNewRole: 'Requested new role "{{name}}" (configs: {{configs}}, IPs: {{ip}})',
type: {
BugReport: 'Bug/suggestion',
RoleRequest: 'Role request',
},
status: {
Open: 'Open',
Resolved: 'Resolved',
Closed: 'Closed',
},
},
settings: {
changePassword: 'Change password',
currentPassword: 'Current password',
currentPasswordRequired: 'Enter your current password.',
currentPasswordInvalid: 'Current password is incorrect.',
newPassword: 'New password',
passwordChanged: 'Password changed.',
changeUserName: 'Change username',
changeUserNameHint: 'Current username: {{current}}.',
newUserName: 'New username',
userNameChanged: 'Username changed.',
telegramHint: 'Linking Telegram enables passwordless login and account recovery.',
telegramLinkedStatus: 'Linked',
link: 'Link Telegram',
unlink: 'Unlink',
confirmUnlink: 'Unlink Telegram from your account?',
telegramLinked: 'Telegram linked.',
telegramUnlinked: 'Telegram unlinked.',
waitingForLink: 'Waiting for confirmation in Telegram…',
deleteAccount: 'Delete account',
deleteAccountHint: 'Revokes all configs and permanently deletes your account.',
confirmDelete: 'Are you sure? This cannot be undone.',
confirmDeleteYes: 'Yes, delete',
cancel: 'Cancel',
},
admin: {
prev: 'Previous',
next: 'Next',
tabs: {
overview: 'Overview',
activation: 'Activation requests',
users: 'Users',
configs: 'All configs',
roles: 'Roles',
pricing: 'Pricing',
nodes: 'Nodes',
apps: 'Apps',
instructions: 'Instructions',
news: 'News',
support: 'Tickets',
audit: 'Audit',
maintenance: 'Maintenance',
},
users: {
searchPlaceholder: 'Search by username',
userName: 'Username',
role: 'Role',
statusLabel: 'Status',
manage: 'Manage',
empty: 'No users found.',
total: 'Total: {{count}}',
status: {
blocked: 'Blocked',
active: 'Active',
pending: 'Not activated',
},
block: 'Block',
unblock: 'Unblock',
confirmBlock: 'Block this user? All their configs will be disabled.',
blocked: 'User blocked.',
unblocked: 'User unblocked.',
roleChanged: 'Role changed.',
resetPassword: 'Reset password',
reset: 'Reset',
passwordReset: 'Password reset.',
configs: 'Configs',
delete: 'Delete user',
confirmDelete: 'Delete this user? All their configs will be revoked in 3x-ui — this cannot be undone.',
deleted: 'User deleted.',
},
configs: {
searchPlaceholder: 'Search by panel email or label',
owner: 'Owner',
node: 'Node',
location: 'Location',
protocol: 'Protocol',
traffic: 'Traffic',
statusLabel: 'Status',
allStatuses: 'All statuses',
created: 'Created',
empty: 'No configs found.',
total: 'Total: {{count}}',
},
activation: {
empty: 'No pending activation requests.',
approved: 'User activated.',
rejected: 'Request rejected.',
approve: 'Approve',
reject: 'Reject',
},
roles: {
create: 'Create role',
name: 'Name',
maxConfigs: 'Config quota',
maxConfigsHint: '1 = unlimited.',
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '1 = unlimited. Applies to new configs only.',
totalPerQuarter: 'Total / 3 months',
totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
system: 'system',
edit: 'Edit',
save: 'Save',
delete: 'Delete',
confirmDelete: 'Delete this role? This cannot be undone.',
created: 'Role created.',
updated: 'Quota updated.',
deleted: 'Role deleted.',
},
pricing: {
title: 'Reference config price',
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
pricePerConfigPerHalfYear: 'Price per config per month / billed every 6 months',
pricePerConfigPerHalfYearHint: 'Can be lower than the quarterly rate (discount for a 6-month prepayment), but the 6-month total must not be cheaper than the 3-month total.',
halfYearCheaperThanQuarter: 'The 6-month price (over 6 months) cannot be lower than the 3-month price.',
pricePerConfigPerYear: 'Price per config per month / billed yearly',
pricePerConfigPerYearHint: 'Can be lower than the 6-month rate (annual discount), but the yearly total must not be cheaper than the 6-month total.',
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
},
nodes: {
create: 'Add node',
name: 'Name',
baseAddress: 'Panel address',
username: 'Username',
password: 'Password',
location: 'Location',
empty: 'No nodes added yet.',
created: 'Node added.',
updated: 'Node updated.',
deleted: 'Node deleted.',
confirmDelete: 'Delete this node? Existing configs on it will stop syncing.',
edit: 'Edit',
delete: 'Delete',
probe: 'Probe',
sync: 'Sync',
probeSuccess: 'Node is reachable.',
probeFailure: 'Node unreachable: {{message}}',
syncSuccess: 'Synced inbounds: {{count}}',
status: {
Unknown: 'Unknown',
Online: 'Online',
Offline: 'Offline',
},
enabled: 'Enabled',
disabled: 'Disabled',
inbounds: 'Inbounds',
noInbounds: 'No inbounds found — click "Sync".',
publish: 'Publishing',
published: 'Published',
unpublished: 'Not published',
unavailable: 'Unavailable on panel',
publishSaved: 'Publishing settings saved.',
displayName: 'Display name',
allowedRoles: 'Allowed for roles',
isPublishedLabel: 'Publish inbound',
optional: 'optional',
},
apps: {
create: 'Add app',
name: 'Name',
downloadUrl: 'Download link',
os: 'OS',
description: 'Description',
iconUrl: 'Icon URL',
sortOrder: 'Sort order',
enabled: 'Enabled',
disabled: 'disabled',
recommended: 'Recommended',
empty: 'The app catalog is empty.',
created: 'App added.',
updated: 'App updated.',
deleted: 'App deleted.',
confirmDelete: 'Remove this app from the catalog?',
},
instructions: {
introTitle: 'Main instruction',
introHint: 'Shown above the tabs on the Instructions page (Markdown).',
introSaved: 'Instruction saved.',
tabsTitle: 'Additional tabs',
tabsEmpty: 'No additional tabs yet.',
createTab: 'Add tab',
tabTitle: 'Tab title',
tabBody: 'Body (Markdown)',
tabCreated: 'Tab added.',
tabUpdated: 'Tab updated.',
tabDeleted: 'Tab deleted.',
confirmDeleteTab: 'Delete this instruction tab?',
},
news: {
create: 'Add post',
title: 'Title',
body: 'Body (Markdown)',
preview: 'Preview',
empty: 'No news yet.',
created: 'Post published.',
updated: 'Post updated.',
deleted: 'Post deleted.',
confirmDelete: 'Delete this post?',
},
support: {
empty: 'No tickets yet.',
resolve: 'Resolve',
resolved: 'Ticket marked as resolved.',
close: 'Close',
closed: 'Ticket closed.',
approve: 'Approve',
approved: 'Request approved, role granted.',
reject: 'Reject',
rejected: 'Request rejected.',
},
audit: {
time: 'Time',
action: 'Action',
target: 'Target',
source: 'Source',
empty: 'The audit log is empty.',
},
maintenance: {
closedTickets: {
title: 'Closed tickets',
description: 'Delete all closed tickets along with their comments and attachments (files). This cannot be undone.',
action: 'Delete closed tickets',
confirm: 'Delete all closed tickets and their files? This cannot be undone.',
deleted: 'Deleted tickets: {{count}}.',
},
audit: {
title: 'Audit log',
description: 'Delete audit log entries older than the given number of days. This cannot be undone.',
olderThan: 'Older than',
days: 'days',
action: 'Delete old entries',
confirm: 'Delete audit entries older than {{days}} days? This cannot be undone.',
deleted: 'Deleted audit entries: {{count}}.',
},
apps: {
title: 'Disabled apps',
description: 'Delete all disabled apps from the catalog. This cannot be undone.',
action: 'Delete disabled apps',
confirm: 'Delete all disabled apps? This cannot be undone.',
deleted: 'Deleted apps: {{count}}.',
},
factoryReset: {
dangerZone: 'Danger zone',
title: 'Full panel reset',
description: 'Return the panel to a freshly-deployed state — deletes all users, configs, nodes and data. Your current account is kept.',
action: 'Reset panel',
dialogTitle: 'Full panel reset',
warningIntro: 'This will permanently delete:',
warningItems: [
'All users except your current account',
'All VPN configs (revoked on the 3x-ui nodes first)',
'All nodes and inbounds — 3x-ui server registrations and stored credentials',
'All support tickets with their comments and attachments',
'All news posts',
'The entire audit log',
'All roles except the system admin/user roles',
'The app catalog (will be re-seeded from the default set)',
],
irreversible: 'This cannot be undone.',
confirmPhrase: 'RESET',
confirmInputLabel: 'Type "{{phrase}}" to confirm',
confirmButton: 'Reset the panel permanently',
done: 'The panel has been reset to its initial state.',
},
},
stats: {
totalUsers: 'Total users',
activatedUsers: 'Activated',
pendingActivationRequests: 'Pending activation',
totalNodes: 'Total nodes',
onlineNodes: 'Nodes online',
totalConfigs: 'Total configs',
activeConfigs: 'Active configs',
totalTraffic: 'Total traffic',
},
},
},
},
}
const STORAGE_KEY = 'pnv-lang'
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
void i18n.use(initReactI18next).init({
resources,
lng: stored ?? 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
})
export function setLanguage(lng: string) {
localStorage.setItem(STORAGE_KEY, lng)
void i18n.changeLanguage(lng)
}
export default i18n