import { useState, useMemo, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { adminSettingsApi, SettingDefinition, SettingCategorySummary } from '../api/adminSettings' import { brandingApi } from '../api/branding' import { themeColorsApi } from '../api/themeColors' import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' import { ColorPicker } from '../components/ColorPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' // Icons const CogIcon = () => ( ) const CheckIcon = () => ( ) const RefreshIcon = () => ( ) const LockIcon = () => ( ) const CloseIcon = () => ( ) const ChevronRightIcon = () => ( ) const ChevronDownIcon = () => ( ) const WarningIcon = () => ( ) const SearchIcon = () => ( ) const UploadIcon = () => ( ) const TrashIcon = () => ( ) const PencilIcon = () => ( ) // Meta-categories for grouping const META_CATEGORIES: Record = { payments: { label: 'Платежные системы', emoji: '💳', categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'], }, subscriptions: { label: 'Подписки и тарифы', emoji: '📅', categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'], }, interface: { label: 'Интерфейс', emoji: '🎨', categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'], }, notifications: { label: 'Уведомления', emoji: '🔔', categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'], }, database: { label: 'База данных', emoji: '💾', categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'], }, system: { label: 'Система', emoji: '⚙️', categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'], }, users: { label: 'Пользователи', emoji: '👥', categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'], }, } // Setting translations (name + description) - comprehensive list const SETTING_TRANSLATIONS: Record = { // CORE BOT_USERNAME: { name: 'Username бота', description: 'Имя бота в Telegram' }, // SUPPORT SUPPORT_USERNAME: { name: 'Username поддержки', description: 'Telegram @username поддержки' }, SUPPORT_MENU_ENABLED: { name: 'Меню поддержки', description: 'Показывать раздел поддержки' }, SUPPORT_SYSTEM_MODE: { name: 'Режим поддержки', description: 'tickets/contact/both' }, MINIAPP_TICKETS_ENABLED: { name: 'Тикеты в MiniApp', description: 'Включить раздел тикетов в MiniApp' }, MINIAPP_SUPPORT_TYPE: { name: 'Тип поддержки MiniApp', description: 'tickets - показать тикеты, profile - перейти в профиль бота, url - открыть внешнюю ссылку' }, MINIAPP_SUPPORT_URL: { name: 'URL поддержки MiniApp', description: 'Внешняя ссылка для поддержки (только для type=url)' }, SUPPORT_TICKET_SLA_ENABLED: { name: 'SLA', description: 'Контроль времени ответа' }, SUPPORT_TICKET_SLA_MINUTES: { name: 'SLA (мин)', description: 'Время на ответ' }, SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: { name: 'Интервал SLA', description: 'Проверка просроченных (сек)' }, SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: { name: 'Кулдаун SLA', description: 'Мин. между напоминаниями' }, SUPPORT_TOPUP_ENABLED: { name: 'Пополнение', description: 'Кнопка пополнения в поддержке' }, // ADMIN NOTIFICATIONS ADMIN_NOTIFICATIONS_ENABLED: { name: 'Уведомления', description: 'Отправлять админам' }, ADMIN_NOTIFICATIONS_CHAT_ID: { name: 'Chat ID', description: 'ID чата для уведомлений' }, ADMIN_NOTIFICATIONS_TOPIC_ID: { name: 'Topic ID', description: 'ID топика' }, ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: { name: 'Topic тикетов', description: 'ID топика тикетов' }, // ADMIN REPORTS ADMIN_REPORTS_ENABLED: { name: 'Отчёты', description: 'Автоматические отчёты' }, ADMIN_REPORTS_CHAT_ID: { name: 'Chat ID', description: 'ID чата отчётов' }, ADMIN_REPORTS_TOPIC_ID: { name: 'Topic ID', description: 'ID топика отчётов' }, ADMIN_REPORTS_SEND_TIME: { name: 'Время', description: 'Время отправки (HH:MM)' }, // CHANNEL CHANNEL_SUB_ID: { name: 'ID канала', description: 'Telegram ID канала' }, CHANNEL_LINK: { name: 'Ссылка', description: 'Ссылка на канал' }, CHANNEL_IS_REQUIRED_SUB: { name: 'Обязательно', description: 'Требовать подписку' }, // LOCALIZATION DEFAULT_LANGUAGE: { name: 'Язык', description: 'Язык по умолчанию' }, AVAILABLE_LANGUAGES: { name: 'Языки', description: 'Доступные языки' }, LANGUAGE_SELECTION_ENABLED: { name: 'Выбор языка', description: 'Разрешить выбор' }, LOCALES_PATH: { name: 'Путь', description: 'Путь к локалям' }, // TIMEZONE TIMEZONE: { name: 'Часовой пояс', description: 'Временная зона' }, // SUBSCRIPTIONS DEFAULT_DEVICE_LIMIT: { name: 'Устройств', description: 'По умолчанию' }, DEFAULT_TRAFFIC_LIMIT_GB: { name: 'Трафик (ГБ)', description: '0 = безлимит' }, MAX_DEVICES_LIMIT: { name: 'Макс. устройств', description: 'Максимум' }, PRICE_PER_DEVICE: { name: 'Цена/устройство', description: 'Доплата (коп.)' }, DEVICES_SELECTION_ENABLED: { name: 'Выбор устройств', description: 'Разрешить выбор' }, DEVICES_SELECTION_DISABLED_AMOUNT: { name: 'При откл. выборе', description: 'Кол-во устройств' }, BASE_SUBSCRIPTION_PRICE: { name: 'Базовая цена', description: 'Стоимость (коп.)' }, BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: { name: 'Скидки', description: 'На длинные периоды' }, BASE_PROMO_GROUP_PERIOD_DISCOUNTS: { name: 'Настройка скидок', description: 'дни:процент' }, SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS: { name: 'Порог баланса', description: 'Для автопродления' }, // SIMPLE SUBSCRIPTION SIMPLE_SUBSCRIPTION_ENABLED: { name: 'Быстрая покупка', description: 'Показывать кнопку' }, SIMPLE_SUBSCRIPTION_PERIOD_DAYS: { name: 'Период (дней)', description: 'Длительность' }, SIMPLE_SUBSCRIPTION_DEVICE_LIMIT: { name: 'Устройств', description: 'В быстрой покупке' }, SIMPLE_SUBSCRIPTION_TRAFFIC_GB: { name: 'Трафик (ГБ)', description: '0 = безлимит' }, SIMPLE_SUBSCRIPTION_SQUAD_UUID: { name: 'UUID сквада', description: 'Пусто = любой' }, // PERIODS AVAILABLE_SUBSCRIPTION_PERIODS: { name: 'Периоды покупки', description: 'Дни через запятую' }, AVAILABLE_RENEWAL_PERIODS: { name: 'Периоды продления', description: 'Дни через запятую' }, // PRICES PRICE_14_DAYS: { name: 'Цена 14 дн', description: 'Копейки' }, PRICE_30_DAYS: { name: 'Цена 30 дн', description: 'Копейки' }, PRICE_60_DAYS: { name: 'Цена 60 дн', description: 'Копейки' }, PRICE_90_DAYS: { name: 'Цена 90 дн', description: 'Копейки' }, PRICE_180_DAYS: { name: 'Цена 180 дн', description: 'Копейки' }, PRICE_360_DAYS: { name: 'Цена 360 дн', description: 'Копейки' }, PAID_SUBSCRIPTION_USER_TAG: { name: 'Тег платного', description: 'Тег в RemnaWave' }, // TRAFFIC DEFAULT_TRAFFIC_RESET_STRATEGY: { name: 'Сброс трафика', description: 'Стратегия обнуления' }, RESET_TRAFFIC_ON_PAYMENT: { name: 'Сброс при оплате', description: 'Обнулять трафик' }, TRAFFIC_SELECTION_MODE: { name: 'Режим выбора', description: 'Как выбирать трафик' }, FIXED_TRAFFIC_LIMIT_GB: { name: 'Фикс. лимит', description: 'ГБ' }, TRAFFIC_PACKAGES_CONFIG: { name: 'Пакеты', description: 'JSON конфиг' }, PRICE_TRAFFIC_5GB: { name: '5 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_10GB: { name: '10 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_25GB: { name: '25 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_50GB: { name: '50 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_100GB: { name: '100 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_250GB: { name: '250 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_500GB: { name: '500 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_1000GB: { name: '1000 ГБ', description: 'Копейки' }, PRICE_TRAFFIC_UNLIMITED: { name: 'Безлимит', description: 'Копейки' }, // TRIAL TRIAL_DURATION_DAYS: { name: 'Дней триала', description: 'Длительность' }, TRIAL_TRAFFIC_LIMIT_GB: { name: 'Трафик (ГБ)', description: 'В триале' }, TRIAL_DEVICE_LIMIT: { name: 'Устройств', description: 'В триале' }, TRIAL_ADD_REMAINING_DAYS_TO_PAID: { name: 'Перенос дней', description: 'К платной подписке' }, TRIAL_PAYMENT_ENABLED: { name: 'Платный триал', description: 'Взимать плату' }, TRIAL_ACTIVATION_PRICE: { name: 'Цена', description: 'Активация (коп.)' }, TRIAL_USER_TAG: { name: 'Тег триала', description: 'Тег в RemnaWave' }, TRIAL_WARNING_HOURS: { name: 'Предупреждение', description: 'За X часов' }, // AUTOPAY AUTOPAY_WARNING_DAYS: { name: 'Дни уведомлений', description: 'Через запятую' }, DEFAULT_AUTOPAY_ENABLED: { name: 'Автопродление', description: 'По умолчанию' }, DEFAULT_AUTOPAY_DAYS_BEFORE: { name: 'Дней до', description: 'Когда продлять' }, MIN_BALANCE_FOR_AUTOPAY_KOPEKS: { name: 'Мин. баланс', description: 'Для продления (коп.)' }, // REFERRAL REFERRAL_PROGRAM_ENABLED: { name: 'Программа', description: 'Включить рефералку' }, REFERRAL_MINIMUM_TOPUP_KOPEKS: { name: 'Мин. пополнение', description: 'Для бонуса (коп.)' }, REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: { name: 'Бонус новичку', description: 'Копейки' }, REFERRAL_INVITER_BONUS_KOPEKS: { name: 'Бонус инвайтеру', description: 'Копейки' }, REFERRAL_COMMISSION_PERCENT: { name: 'Комиссия %', description: 'От платежей' }, REFERRAL_NOTIFICATIONS_ENABLED: { name: 'Уведомления', description: 'О бонусах' }, // NOTIFICATIONS ENABLE_NOTIFICATIONS: { name: 'Уведомления', description: 'Пользователям' }, NOTIFICATION_RETRY_ATTEMPTS: { name: 'Попыток', description: 'Отправки' }, NOTIFICATION_CACHE_HOURS: { name: 'Кэш (ч)', description: 'Часов' }, MONITORING_LOGS_RETENTION_DAYS: { name: 'Хранить логи', description: 'Дней' }, // PAYMENT PAYMENT_SERVICE_NAME: { name: 'Название', description: 'В документах' }, PAYMENT_BALANCE_DESCRIPTION: { name: 'Описание пополнения', description: 'В чеке' }, PAYMENT_SUBSCRIPTION_DESCRIPTION: { name: 'Описание подписки', description: 'В чеке' }, PAYMENT_BALANCE_TEMPLATE: { name: 'Шаблон', description: 'Пополнения' }, PAYMENT_SUBSCRIPTION_TEMPLATE: { name: 'Шаблон', description: 'Подписки' }, AUTO_PURCHASE_AFTER_TOPUP_ENABLED: { name: 'Автопокупка', description: 'После пополнения' }, DISABLE_TOPUP_BUTTONS: { name: 'Скрыть', description: 'Кнопки пополнения' }, DISABLE_WEB_PAGE_PREVIEW: { name: 'Без превью', description: 'Ссылок' }, PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED: { name: 'Автопроверка', description: 'Платежей' }, PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: { name: 'Интервал', description: 'Минут' }, // TELEGRAM STARS TELEGRAM_STARS_ENABLED: { name: 'Stars', description: 'Принимать оплату' }, TELEGRAM_STARS_RATE_RUB: { name: 'Курс', description: 'Рублей за Star' }, // YOOKASSA YOOKASSA_ENABLED: { name: 'YooKassa', description: 'Включить' }, YOOKASSA_SHOP_ID: { name: 'Shop ID', description: 'Идентификатор' }, YOOKASSA_SECRET_KEY: { name: 'Секрет', description: 'Ключ API' }, YOOKASSA_RETURN_URL: { name: 'URL возврата', description: 'После оплаты' }, YOOKASSA_DEFAULT_RECEIPT_EMAIL: { name: 'Email чеков', description: 'По умолчанию' }, YOOKASSA_VAT_CODE: { name: 'НДС', description: 'Код (1-6)' }, YOOKASSA_SBP_ENABLED: { name: 'СБП', description: 'Оплата СБП' }, YOOKASSA_PAYMENT_MODE: { name: 'Режим', description: 'Тип платежа' }, YOOKASSA_PAYMENT_SUBJECT: { name: 'Предмет', description: 'Тип товара' }, YOOKASSA_WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, YOOKASSA_WEBHOOK_HOST: { name: 'Хост', description: 'Вебхука' }, YOOKASSA_WEBHOOK_PORT: { name: 'Порт', description: 'Вебхука' }, YOOKASSA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, YOOKASSA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED: { name: 'Быстрый выбор', description: 'Суммы' }, // CRYPTOBOT CRYPTOBOT_ENABLED: { name: 'CryptoBot', description: 'Включить' }, CRYPTOBOT_API_TOKEN: { name: 'Токен', description: 'API' }, CRYPTOBOT_WEBHOOK_SECRET: { name: 'Секрет', description: 'Вебхука' }, CRYPTOBOT_BASE_URL: { name: 'URL', description: 'API' }, CRYPTOBOT_TESTNET: { name: 'Тестнет', description: 'Тестовая сеть' }, CRYPTOBOT_WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, CRYPTOBOT_WEBHOOK_PORT: { name: 'Порт', description: 'Вебхука' }, CRYPTOBOT_DEFAULT_ASSET: { name: 'Валюта', description: 'По умолчанию' }, CRYPTOBOT_ASSETS: { name: 'Валюты', description: 'Через запятую' }, CRYPTOBOT_INVOICE_EXPIRES_HOURS: { name: 'Срок (ч)', description: 'Инвойса' }, // HELEKET HELEKET_ENABLED: { name: 'Heleket', description: 'Включить' }, HELEKET_MERCHANT_ID: { name: 'Merchant ID', description: 'ID мерчанта' }, HELEKET_API_KEY: { name: 'API Key', description: 'Ключ' }, HELEKET_BASE_URL: { name: 'URL', description: 'API' }, HELEKET_DEFAULT_CURRENCY: { name: 'Валюта', description: 'По умолчанию' }, HELEKET_DEFAULT_NETWORK: { name: 'Сеть', description: 'По умолчанию' }, HELEKET_INVOICE_LIFETIME: { name: 'Срок (сек)', description: 'Инвойса' }, HELEKET_MARKUP_PERCENT: { name: 'Наценка %', description: 'Процент' }, // PLATEGA PLATEGA_ENABLED: { name: 'Platega', description: 'Включить' }, PLATEGA_MERCHANT_ID: { name: 'Merchant ID', description: 'ID' }, PLATEGA_SECRET: { name: 'Секрет', description: 'Ключ' }, PLATEGA_BASE_URL: { name: 'URL', description: 'API' }, PLATEGA_RETURN_URL: { name: 'URL возврата', description: 'После оплаты' }, PLATEGA_FAILED_URL: { name: 'URL ошибки', description: 'При ошибке' }, PLATEGA_CURRENCY: { name: 'Валюта', description: 'Платежей' }, PLATEGA_ACTIVE_METHODS: { name: 'Методы', description: 'ID через запятую' }, PLATEGA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, PLATEGA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, // TRIBUTE TRIBUTE_ENABLED: { name: 'Tribute', description: 'Включить' }, TRIBUTE_API_KEY: { name: 'API Key', description: 'Ключ' }, TRIBUTE_DONATE_LINK: { name: 'Ссылка', description: 'На донат' }, // MULENPAY MULENPAY_ENABLED: { name: 'MulenPay', description: 'Включить' }, MULENPAY_API_KEY: { name: 'API Key', description: 'Ключ' }, MULENPAY_SECRET_KEY: { name: 'Секрет', description: 'Ключ' }, MULENPAY_SHOP_ID: { name: 'Shop ID', description: 'ID магазина' }, MULENPAY_DISPLAY_NAME: { name: 'Название', description: 'Отображаемое' }, MULENPAY_LANGUAGE: { name: 'Язык', description: 'Интерфейса' }, MULENPAY_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, MULENPAY_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, // PAL24 PAL24_ENABLED: { name: 'PAL24', description: 'Включить' }, PAL24_API_TOKEN: { name: 'Токен', description: 'API' }, PAL24_SHOP_ID: { name: 'Shop ID', description: 'ID' }, PAL24_SIGNATURE_TOKEN: { name: 'Подпись', description: 'Токен' }, PAL24_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, PAL24_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, PAL24_SBP_BUTTON_VISIBLE: { name: 'СБП', description: 'Показывать' }, PAL24_CARD_BUTTON_VISIBLE: { name: 'Карта', description: 'Показывать' }, // WATA WATA_ENABLED: { name: 'Wata', description: 'Включить' }, WATA_ACCESS_TOKEN: { name: 'Токен', description: 'Доступа' }, WATA_TERMINAL_PUBLIC_ID: { name: 'Terminal ID', description: 'Публичный ID' }, WATA_PAYMENT_TYPE: { name: 'Тип', description: 'Платежа' }, WATA_MIN_AMOUNT_KOPEKS: { name: 'Мин.', description: 'Копейки' }, WATA_MAX_AMOUNT_KOPEKS: { name: 'Макс.', description: 'Копейки' }, // EXTERNAL ADMIN EXTERNAL_ADMIN_TOKEN: { name: 'Токен', description: 'Только чтение' }, EXTERNAL_ADMIN_TOKEN_BOT_ID: { name: 'ID бота', description: 'Связанного' }, // INTERFACE MAIN_MENU_MODE: { name: 'Режим меню', description: 'default/text' }, ENABLE_LOGO_MODE: { name: 'Логотип', description: 'В сообщениях' }, LOGO_FILE: { name: 'Файл', description: 'Логотипа' }, HIDE_SUBSCRIPTION_LINK: { name: 'Скрыть ссылку', description: 'На подписку' }, CONNECT_BUTTON_MODE: { name: 'Подключение', description: 'Режим кнопки' }, CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: { name: 'Happ', description: 'Кнопка скачивания' }, // MINIAPP MINIAPP_CUSTOM_URL: { name: 'URL', description: 'Mini App' }, MINIAPP_STATIC_PATH: { name: 'Статика', description: 'Путь' }, MINIAPP_PURCHASE_URL: { name: 'URL покупки', description: 'Страница' }, MINIAPP_SERVICE_NAME_EN: { name: 'Название EN', description: 'Английское' }, MINIAPP_SERVICE_NAME_RU: { name: 'Название RU', description: 'Русское' }, // HAPP HAPP_DOWNLOAD_LINK_IOS: { name: 'iOS', description: 'Ссылка' }, HAPP_DOWNLOAD_LINK_ANDROID: { name: 'Android', description: 'Ссылка' }, HAPP_DOWNLOAD_LINK_MACOS: { name: 'macOS', description: 'Ссылка' }, HAPP_DOWNLOAD_LINK_WINDOWS: { name: 'Windows', description: 'Ссылка' }, // SKIP SKIP_RULES_ACCEPT: { name: 'Пропуск правил', description: 'Не показывать' }, SKIP_REFERRAL_CODE: { name: 'Пропуск кода', description: 'Реферального' }, // ADDITIONAL APP_CONFIG_PATH: { name: 'app-config', description: 'Путь' }, ENABLE_DEEP_LINKS: { name: 'Deep Links', description: 'Включить' }, APP_CONFIG_CACHE_TTL: { name: 'TTL кэша', description: 'Секунд' }, // DATABASE DATABASE_MODE: { name: 'Режим БД', description: 'auto/postgresql/sqlite' }, DATABASE_URL: { name: 'URL', description: 'Подключения' }, POSTGRES_HOST: { name: 'Хост', description: 'PostgreSQL' }, POSTGRES_PORT: { name: 'Порт', description: 'PostgreSQL' }, POSTGRES_DB: { name: 'База', description: 'Имя' }, POSTGRES_USER: { name: 'Пользователь', description: 'Логин' }, POSTGRES_PASSWORD: { name: 'Пароль', description: 'PostgreSQL' }, SQLITE_PATH: { name: 'Путь', description: 'SQLite' }, REDIS_URL: { name: 'URL', description: 'Redis' }, // REMNAWAVE REMNAWAVE_API_URL: { name: 'URL панели', description: 'Адрес' }, REMNAWAVE_API_KEY: { name: 'API Key', description: 'Ключ' }, REMNAWAVE_SECRET_KEY: { name: 'Секрет', description: 'Ключ' }, REMNAWAVE_USERNAME: { name: 'Логин', description: 'Basic auth' }, REMNAWAVE_PASSWORD: { name: 'Пароль', description: 'Basic auth' }, REMNAWAVE_AUTH_TYPE: { name: 'Тип авторизации', description: 'api_key/basic_auth' }, REMNAWAVE_USER_DESCRIPTION_TEMPLATE: { name: 'Шаблон описания', description: 'Пользователя' }, REMNAWAVE_USER_USERNAME_TEMPLATE: { name: 'Шаблон username', description: 'Пользователя' }, REMNAWAVE_USER_DELETE_MODE: { name: 'Режим удаления', description: 'delete/disable' }, REMNAWAVE_AUTO_SYNC_ENABLED: { name: 'Автосинхр.', description: 'Включить' }, REMNAWAVE_AUTO_SYNC_TIMES: { name: 'Время', description: 'Синхронизации (HH:MM)' }, // SERVER STATUS SERVER_STATUS_MODE: { name: 'Режим', description: 'Статуса серверов' }, SERVER_STATUS_EXTERNAL_URL: { name: 'URL', description: 'Внешний' }, SERVER_STATUS_METRICS_URL: { name: 'Метрики', description: 'URL' }, // MONITORING MONITORING_INTERVAL: { name: 'Интервал', description: 'Секунд' }, TRAFFIC_MONITORING_ENABLED: { name: 'Мониторинг', description: 'Трафика' }, TRAFFIC_THRESHOLD_GB_PER_DAY: { name: 'Порог', description: 'ГБ/день' }, // MAINTENANCE MAINTENANCE_MODE: { name: 'Обслуживание', description: 'Включить' }, MAINTENANCE_MESSAGE: { name: 'Сообщение', description: 'Текст' }, MAINTENANCE_CHECK_INTERVAL: { name: 'Интервал', description: 'Секунд' }, MAINTENANCE_AUTO_ENABLE: { name: 'Авто', description: 'Включение' }, MAINTENANCE_MONITORING_ENABLED: { name: 'Мониторинг', description: 'Панели' }, MAINTENANCE_RETRY_ATTEMPTS: { name: 'Попыток', description: 'Перед включением' }, INACTIVE_USER_DELETE_MONTHS: { name: 'Удаление', description: 'Через X месяцев' }, // BACKUP BACKUP_AUTO_ENABLED: { name: 'Автобэкап', description: 'Включить' }, BACKUP_INTERVAL_HOURS: { name: 'Интервал', description: 'Часов' }, BACKUP_TIME: { name: 'Время', description: 'Создания' }, BACKUP_MAX_KEEP: { name: 'Хранить', description: 'Копий' }, BACKUP_COMPRESSION: { name: 'Сжатие', description: 'Включить' }, BACKUP_INCLUDE_LOGS: { name: 'Логи', description: 'Включать' }, BACKUP_LOCATION: { name: 'Путь', description: 'Сохранения' }, BACKUP_SEND_ENABLED: { name: 'Отправка', description: 'В Telegram' }, BACKUP_SEND_CHAT_ID: { name: 'Chat ID', description: 'Для отправки' }, // VERSION VERSION_CHECK_ENABLED: { name: 'Проверка', description: 'Обновлений' }, VERSION_CHECK_REPO: { name: 'Репозиторий', description: 'GitHub' }, VERSION_CHECK_INTERVAL_HOURS: { name: 'Интервал', description: 'Часов' }, // WEB API WEB_API_ENABLED: { name: 'Web API', description: 'Включить' }, WEB_API_HOST: { name: 'Хост', description: 'IP' }, WEB_API_PORT: { name: 'Порт', description: 'API' }, WEB_API_WORKERS: { name: 'Воркеров', description: 'Количество' }, WEB_API_ALLOWED_ORIGINS: { name: 'CORS', description: 'Origins' }, WEB_API_DOCS_ENABLED: { name: 'Документация', description: 'Swagger' }, WEB_API_DEFAULT_TOKEN: { name: 'Токен', description: 'По умолчанию' }, WEB_API_REQUEST_LOGGING: { name: 'Логирование', description: 'Запросов' }, // WEBHOOK WEBHOOK_URL: { name: 'URL', description: 'Вебхука' }, WEBHOOK_PATH: { name: 'Путь', description: 'Вебхука' }, WEBHOOK_SECRET_TOKEN: { name: 'Секрет', description: 'Токен' }, BOT_RUN_MODE: { name: 'Режим', description: 'polling/webhook' }, // LOG LOG_LEVEL: { name: 'Уровень', description: 'Логирования' }, LOG_FILE: { name: 'Файл', description: 'Логов' }, // DEBUG DEBUG: { name: 'Отладка', description: 'Включить' }, // MODERATION DISPLAY_NAME_BANNED_KEYWORDS: { name: 'Запрещённые', description: 'Слова в именах' }, // CONTESTS CONTESTS_ENABLED: { name: 'Конкурсы', description: 'Включить' }, CONTESTS_BUTTON_VISIBLE: { name: 'Кнопка', description: 'Конкурсов' }, // CABINET CABINET_ENABLED: { name: 'Кабинет', description: 'Веб-кабинет' }, CABINET_JWT_SECRET: { name: 'JWT секрет', description: 'Ключ' }, } // Extract emoji from label const extractEmoji = (label: string): { emoji: string; text: string } => { const emojiMatch = label.match(/^(\p{Emoji})/u) if (emojiMatch) { return { emoji: emojiMatch[1], text: label.slice(emojiMatch[1].length).trim() } } return { emoji: '⚙️', text: label } } // Get translated setting name and description const getSettingTranslation = (key: string, originalName: string) => { const translation = SETTING_TRANSLATIONS[key] return { name: translation?.name || originalName, description: translation?.description || '', } } interface SettingRowProps { setting: SettingDefinition onUpdate: (key: string, value: unknown) => void onReset: (key: string) => void isUpdating: boolean } function SettingRow({ setting, onUpdate, onReset, isUpdating }: SettingRowProps) { const { t } = useTranslation() const [editValue, setEditValue] = useState( setting.current !== null && setting.current !== undefined ? String(setting.current) : '' ) const [isEditing, setIsEditing] = useState(false) const translation = getSettingTranslation(setting.key, setting.name) const handleSave = () => { let valueToSave: unknown = editValue if (setting.type === 'bool') { valueToSave = editValue === 'true' } else if (setting.type === 'int') { valueToSave = parseInt(editValue, 10) } else if (setting.type === 'float') { valueToSave = parseFloat(editValue) } onUpdate(setting.key, valueToSave) setIsEditing(false) } const handleReset = () => { onReset(setting.key) setEditValue(setting.original !== null && setting.original !== undefined ? String(setting.original) : '') } const formatValue = (value: unknown): string => { if (value === null || value === undefined) return '-' if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no') return String(value) } const renderInput = () => { if (setting.read_only) { return (
{formatValue(setting.current)}
) } if (setting.choices && setting.choices.length > 0) { return ( ) } if (setting.type === 'bool') { return ( ) } if (isEditing) { return (
setEditValue(e.target.value)} className="input py-1.5 text-sm flex-1" autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleSave() if (e.key === 'Escape') { setIsEditing(false) setEditValue(String(setting.current ?? '')) } }} />
) } return ( ) } return (
{translation.name} {setting.has_override && ( {t('admin.settings.modified')} )} {setting.read_only && ( {t('admin.settings.readOnly')} )}
{translation.description && (

{translation.description}

)}
{setting.has_override && !setting.read_only && ( )}
{renderInput()}
{/* Warning from hint */} {setting.hint?.warning && (

{setting.hint.warning}

)}
) } interface MetaCategoryCardProps { metaKey: string meta: { label: string; emoji: string; categories: string[] } categoriesData: SettingCategorySummary[] settingsCount: number onClick: () => void } function MetaCategoryCard({ meta, categoriesData, settingsCount, onClick }: MetaCategoryCardProps) { return ( ) } interface SettingsModalProps { metaKey: string meta: { label: string; emoji: string; categories: string[] } categories: SettingCategorySummary[] allSettings: SettingDefinition[] onClose: () => void onUpdate: (key: string, value: unknown) => void onReset: (key: string) => void isUpdating: boolean } function SettingsModal({ meta, categories, allSettings, onClose, onUpdate, onReset, isUpdating }: SettingsModalProps) { const { t } = useTranslation() const [expandedCategories, setExpandedCategories] = useState>(new Set([categories[0]?.key])) const [searchQuery, setSearchQuery] = useState('') const toggleCategory = (key: string) => { setExpandedCategories((prev) => { const next = new Set(prev) if (next.has(key)) { next.delete(key) } else { next.add(key) } return next }) } // Group settings by category const settingsByCategory = useMemo(() => { const result: Record = {} for (const category of categories) { const categorySettings = allSettings.filter((s) => s.category.key === category.key) if (searchQuery.trim()) { const query = searchQuery.toLowerCase() const filtered = categorySettings.filter((s) => { const translation = getSettingTranslation(s.key, s.name) return ( translation.name.toLowerCase().includes(query) || translation.description.toLowerCase().includes(query) || s.key.toLowerCase().includes(query) || String(s.current).toLowerCase().includes(query) ) }) if (filtered.length > 0) { result[category.key] = filtered } } else { if (categorySettings.length > 0) { result[category.key] = categorySettings } } } return result }, [categories, allSettings, searchQuery]) // Auto-expand when searching useMemo(() => { if (searchQuery) { setExpandedCategories(new Set(Object.keys(settingsByCategory))) } }, [searchQuery, settingsByCategory]) const totalFiltered = Object.values(settingsByCategory).reduce((acc, arr) => acc + arr.length, 0) return (
{/* Header */}
{meta.emoji}

{meta.label}

{totalFiltered} настроек

{/* Search */}
setSearchQuery(e.target.value)} placeholder={t('admin.settings.searchPlaceholder')} className="input w-full pl-10 py-2" />
{/* Categories with settings */}
{Object.keys(settingsByCategory).length > 0 ? ( categories .filter((cat) => settingsByCategory[cat.key]) .map((category) => { const { emoji, text } = extractEmoji(category.label) const isExpanded = expandedCategories.has(category.key) const categorySettings = settingsByCategory[category.key] || [] return (
{isExpanded && (
{categorySettings.map((setting) => ( ))}
)}
) }) ) : (
{t('admin.settings.noSearchResults')}
)}
{/* Footer */}
) } // Icons for theme toggle const SunIcon = () => ( ) const MoonIcon = () => ( ) export default function AdminSettings() { const { t } = useTranslation() const queryClient = useQueryClient() const [selectedMeta, setSelectedMeta] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [editingName, setEditingName] = useState(false) const [newName, setNewName] = useState('') const fileInputRef = useRef(null) // Branding query and mutations const { data: branding } = useQuery({ queryKey: ['branding'], queryFn: brandingApi.getBranding, }) const updateNameMutation = useMutation({ mutationFn: (name: string) => brandingApi.updateName(name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['branding'] }) setEditingName(false) }, }) const uploadLogoMutation = useMutation({ mutationFn: (file: File) => brandingApi.uploadLogo(file), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['branding'] }) }, }) const deleteLogoMutation = useMutation({ mutationFn: () => brandingApi.deleteLogo(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['branding'] }) }, }) // Theme colors query and mutations const { data: themeColors } = useQuery({ queryKey: ['theme-colors'], queryFn: themeColorsApi.getColors, }) const updateColorsMutation = useMutation({ mutationFn: themeColorsApi.updateColors, onSuccess: (data) => { // Apply colors immediately after successful update applyThemeColors(data) queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) }, }) const resetColorsMutation = useMutation({ mutationFn: themeColorsApi.resetColors, onSuccess: (data) => { // Apply default colors immediately applyThemeColors(data) queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) }, }) // Enabled themes query and mutation const { data: enabledThemes } = useQuery({ queryKey: ['enabled-themes'], queryFn: themeColorsApi.getEnabledThemes, }) const updateEnabledThemesMutation = useMutation({ mutationFn: themeColorsApi.updateEnabledThemes, onSuccess: (data) => { // Update localStorage cache so useTheme hook picks up changes immediately updateEnabledThemesCache(data) queryClient.invalidateQueries({ queryKey: ['enabled-themes'] }) }, }) const handleLogoUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { uploadLogoMutation.mutate(file) } // Reset input so same file can be uploaded again e.target.value = '' } const handleNameSave = () => { // Allow empty name (logo-only mode) updateNameMutation.mutate(newName.trim()) } const startEditingName = () => { setNewName(branding?.name || '') setEditingName(true) } const { data: categories, isLoading: categoriesLoading } = useQuery({ queryKey: ['admin-settings-categories'], queryFn: adminSettingsApi.getCategories, }) const { data: allSettings, isLoading: settingsLoading } = useQuery({ queryKey: ['admin-settings'], queryFn: () => adminSettingsApi.getSettings(), }) const updateMutation = useMutation({ mutationFn: ({ key, value }: { key: string; value: unknown }) => adminSettingsApi.updateSetting(key, value), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) }, }) const resetMutation = useMutation({ mutationFn: (key: string) => adminSettingsApi.resetSetting(key), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) }, }) const handleUpdate = (key: string, value: unknown) => { updateMutation.mutate({ key, value }) } const handleReset = (key: string) => { resetMutation.mutate(key) } // Group categories by meta-category const metaCategoriesData = useMemo(() => { if (!categories || !allSettings) return null const result: Record = {} for (const [metaKey, meta] of Object.entries(META_CATEGORIES)) { const metaCategories = categories.filter((c) => meta.categories.includes(c.key)) const settingsCount = allSettings.filter((s) => meta.categories.includes(s.category.key)).length if (metaCategories.length > 0) { result[metaKey] = { categories: metaCategories, settingsCount } } } // Add "Other" for uncategorized const allMetaCategories = Object.values(META_CATEGORIES).flatMap((m) => m.categories) const otherCategories = categories.filter((c) => !allMetaCategories.includes(c.key)) if (otherCategories.length > 0) { const settingsCount = allSettings.filter( (s) => !allMetaCategories.includes(s.category.key) ).length result['other'] = { categories: otherCategories, settingsCount } } return result }, [categories, allSettings]) // Filter meta-categories by search const filteredMetaCategories = useMemo(() => { if (!metaCategoriesData) return null if (!searchQuery.trim()) return metaCategoriesData const query = searchQuery.toLowerCase() const result: Record = {} for (const [metaKey, data] of Object.entries(metaCategoriesData)) { const meta = META_CATEGORIES[metaKey] || { label: 'Другое', emoji: '📁', categories: [] } if ( meta.label.toLowerCase().includes(query) || data.categories.some((c) => c.label.toLowerCase().includes(query)) ) { result[metaKey] = data } } return result }, [metaCategoriesData, searchQuery]) if (categoriesLoading || settingsLoading) { return (
) } const totalSettings = allSettings?.length || 0 return (
{/* Header */}

{t('admin.settings.title')}

{Object.keys(filteredMetaCategories || {}).length} групп / {totalSettings} настроек

{/* Branding Card */}
🎨

Брендинг

{/* Logo Section */}
{branding?.has_custom_logo && branding.logo_url ? ( Логотип ) : ( {branding?.logo_letter || 'C'} )}
{branding?.has_custom_logo && ( )}

PNG, JPG, WebP, SVG до 2МБ

{/* Name Section */}
{editingName ? (
setNewName(e.target.value)} className="input flex-1" placeholder="Оставьте пустым для режима только логотип" maxLength={50} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave() if (e.key === 'Escape') setEditingName(false) }} />
) : (
{branding?.name ? ( {branding.name} ) : ( Только логотип )} {branding?.name && ( )}
)}

Оставьте пустым, чтобы показывать только логотип

{/* Theme Colors Card */}
🎨

{t('theme.colors')}

{/* Theme Availability Toggles */}
{/* Dark Theme Toggle */}
Тёмная тема

Тёмное оформление интерфейса

{/* Light Theme Toggle */}
Светлая тема

Светлое оформление интерфейса

Включите нужные темы для пользователей. Минимум одна тема должна быть активна.

{/* Accent Color */} updateColorsMutation.mutate({ accent: color })} disabled={updateColorsMutation.isPending} /> {/* Dark Theme Section */}

{t('theme.darkTheme')}

updateColorsMutation.mutate({ darkBackground: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ darkSurface: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ darkText: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ darkTextSecondary: color })} disabled={updateColorsMutation.isPending} />
{/* Light Theme Section */}

{t('theme.lightTheme')}

updateColorsMutation.mutate({ lightBackground: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ lightSurface: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ lightText: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ lightTextSecondary: color })} disabled={updateColorsMutation.isPending} />
{/* Status Colors */}

{t('theme.statusColors')}

updateColorsMutation.mutate({ success: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ warning: color })} disabled={updateColorsMutation.isPending} /> updateColorsMutation.mutate({ error: color })} disabled={updateColorsMutation.isPending} />
{/* Preview */}

{t('theme.preview')}

{t('theme.success')} {t('theme.warning')} {t('theme.error')}
{/* Search */}
setSearchQuery(e.target.value)} placeholder={t('admin.settings.searchCategoriesPlaceholder')} className="input w-full pl-10" />
{searchQuery && ( )}
{/* Meta-category Cards Grid */} {filteredMetaCategories && Object.keys(filteredMetaCategories).length > 0 ? (
{Object.entries(filteredMetaCategories).map(([metaKey, data]) => { const meta = META_CATEGORIES[metaKey] || { label: 'Другое', emoji: '📁', categories: [] } return ( setSelectedMeta(metaKey)} /> ) })}
) : (

{searchQuery ? t('admin.settings.noSearchResults') : t('admin.settings.noSettings')}

)} {/* Settings Modal */} {selectedMeta && metaCategoriesData && metaCategoriesData[selectedMeta] && ( setSelectedMeta(null)} onUpdate={handleUpdate} onReset={handleReset} isUpdating={updateMutation.isPending || resetMutation.isPending} /> )}
) }