diff --git a/src/components/admin/FavoritesTab.tsx b/src/components/admin/FavoritesTab.tsx index d7c223d..9f40bb9 100644 --- a/src/components/admin/FavoritesTab.tsx +++ b/src/components/admin/FavoritesTab.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'; import { StarIcon } from './icons'; -import { SettingRow } from './SettingRow'; +import { SettingsTableRow } from './SettingsTableRow'; interface FavoritesTabProps { settings: SettingDefinition[]; @@ -42,9 +42,9 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites } return ( -
- {settings.map((setting) => ( - + {settings.map((setting, idx) => ( + resetSettingMutation.mutate(setting.key)} isUpdating={updateSettingMutation.isPending} isResetting={resetSettingMutation.isPending} + isLast={idx === settings.length - 1} /> ))}
diff --git a/src/components/admin/QuickToggles.tsx b/src/components/admin/QuickToggles.tsx new file mode 100644 index 0000000..2c40841 --- /dev/null +++ b/src/components/admin/QuickToggles.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from 'react-i18next'; +import { SettingDefinition } from '../../api/adminSettings'; +import { cn } from '../../lib/utils'; +import { formatSettingKey } from './utils'; + +interface QuickTogglesProps { + settings: SettingDefinition[]; + onUpdate: (key: string, value: string) => void; + disabled?: boolean; + className?: string; +} + +export function QuickToggles({ settings, onUpdate, disabled, className }: QuickTogglesProps) { + const { t } = useTranslation(); + + const booleanSettings = settings.filter((s) => s.type === 'bool' && !s.read_only); + + if (booleanSettings.length === 0) { + return null; + } + + return ( +
+ + {t('admin.settings.quickToggles')} + +
+ {booleanSettings.map((setting) => { + const isOn = setting.current === true || setting.current === 'true'; + const formattedKey = formatSettingKey(setting.name || setting.key); + const label = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/admin/SettingsMobileTabs.tsx b/src/components/admin/SettingsMobileTabs.tsx index 607e159..9c8edfc 100644 --- a/src/components/admin/SettingsMobileTabs.tsx +++ b/src/components/admin/SettingsMobileTabs.tsx @@ -1,6 +1,6 @@ import { useTranslation } from 'react-i18next'; -import { useRef, useEffect } from 'react'; -import { MENU_SECTIONS } from './constants'; +import { useRef, useEffect, useState } from 'react'; +import { SETTINGS_TREE } from './constants'; import { StarIcon } from './icons'; interface SettingsMobileTabsProps { @@ -17,6 +17,7 @@ export function SettingsMobileTabs({ const { t } = useTranslation(); const scrollRef = useRef(null); const activeRef = useRef(null); + const [expandedGroup, setExpandedGroup] = useState(null); // Scroll active tab into view useEffect(() => { @@ -26,53 +27,155 @@ export function SettingsMobileTabs({ const containerRect = container.getBoundingClientRect(); const activeRect = activeEl.getBoundingClientRect(); - // Check if active element is not fully visible if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) { activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); } } }, [activeSection]); - // Flatten all items from all sections - const allItems = MENU_SECTIONS.flatMap((section) => section.items); + // Auto-expand the group containing the active section + useEffect(() => { + for (const group of SETTINGS_TREE.groups) { + if (group.children.some((child) => child.id === activeSection)) { + setExpandedGroup(group.id); + return; + } + } + }, [activeSection]); + + const handleGroupTap = (groupId: string) => { + if (expandedGroup === groupId) { + setExpandedGroup(null); + } else { + setExpandedGroup(groupId); + // Auto-select first child when expanding + const group = SETTINGS_TREE.groups.find((g) => g.id === groupId); + if (group && group.children.length > 0) { + setActiveSection(group.children[0].id); + } + } + }; + + const isGroupActive = (groupId: string) => { + const group = SETTINGS_TREE.groups.find((g) => g.id === groupId); + return group?.children.some((child) => child.id === activeSection) ?? false; + }; + + const isFavoritesActive = activeSection === 'favorites'; + + // Check if active section is a special item + const isSpecialActive = (itemId: string) => activeSection === itemId; + + // Special items excluding favorites + const specialItems = SETTINGS_TREE.specialItems.filter((item) => item.id !== 'favorites'); return ( -
- {allItems.map((item) => { - const isActive = activeSection === item.id; - const hasIcon = item.iconType === 'star'; +
+ {/* Level 1: Favorites + special items + group chips */} +
+ {/* Favorites chip */} + - return ( - - ); - })} + {/* Special item chips (branding, theme, analytics, buttons) */} + {specialItems.map((item) => { + const isActive = isSpecialActive(item.id); + return ( + + ); + })} + + {/* Group chips */} + {SETTINGS_TREE.groups.map((group) => { + const hasActiveChild = isGroupActive(group.id); + const isExpanded = expandedGroup === group.id; + return ( + + ); + })} +
+ + {/* Level 2: Sub-item chips (shown when a group is expanded) */} + {expandedGroup && ( +
+ {SETTINGS_TREE.groups + .find((g) => g.id === expandedGroup) + ?.children.map((child) => { + const isActive = activeSection === child.id; + return ( + + ); + })} +
+ )}
); } diff --git a/src/components/admin/SettingsTab.tsx b/src/components/admin/SettingsTab.tsx index 44ebc24..4508136 100644 --- a/src/components/admin/SettingsTab.tsx +++ b/src/components/admin/SettingsTab.tsx @@ -1,9 +1,8 @@ -import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'; -import { ChevronDownIcon } from './icons'; -import { SettingRow } from './SettingRow'; +import { QuickToggles } from './QuickToggles'; +import { SettingsTableRow } from './SettingsTableRow'; interface CategoryGroup { key: string; @@ -29,20 +28,6 @@ export function SettingsTab({ const { t } = useTranslation(); const queryClient = useQueryClient(); - const [expandedSections, setExpandedSections] = useState>(new Set()); - - const toggleSection = (key: string) => { - setExpandedSections((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }; - const updateSettingMutation = useMutation({ mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value), @@ -58,92 +43,80 @@ export function SettingsTab({ }, }); - // If searching, show flat list + // Search mode: flat list of filtered results if (searchQuery) { + if (filteredSettings.length === 0) { + return ( +
+

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

+
+ ); + } return ( -
- {filteredSettings.length === 0 ? ( -
-

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

-
- ) : ( -
- {filteredSettings.map((setting) => ( - toggleFavorite(setting.key)} - onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })} - onReset={() => resetSettingMutation.mutate(setting.key)} - isUpdating={updateSettingMutation.isPending} - isResetting={resetSettingMutation.isPending} - /> - ))} -
- )} +
+ {filteredSettings.map((setting, idx) => ( + toggleFavorite(setting.key)} + onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })} + onReset={() => resetSettingMutation.mutate(setting.key)} + isUpdating={updateSettingMutation.isPending} + isResetting={resetSettingMutation.isPending} + isLast={idx === filteredSettings.length - 1} + /> + ))}
); } - // Show accordion for subcategories - return ( -
- {categories.map((cat) => { - const isExpanded = expandedSections.has(cat.key); - return ( -
- {/* Accordion header */} - + // Normal mode: QuickToggles + settings by category + const allCategorySettings = categories.flatMap((c) => c.settings); - {/* Accordion content */} - {isExpanded && ( -
-
- {cat.settings.map((setting) => ( - toggleFavorite(setting.key)} - onUpdate={(value) => - updateSettingMutation.mutate({ key: setting.key, value }) - } - onReset={() => resetSettingMutation.mutate(setting.key)} - isUpdating={updateSettingMutation.isPending} - isResetting={resetSettingMutation.isPending} - /> - ))} -
+ if (allCategorySettings.length === 0) { + return ( +
+

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

+
+ ); + } + + return ( +
+ updateSettingMutation.mutate({ key, value })} + disabled={updateSettingMutation.isPending} + /> + {categories.map((category) => { + if (category.settings.length === 0) return null; + return ( +
+ {categories.length > 1 && ( +
+

{category.label}

+ {category.settings.length}
)} +
+ {category.settings.map((setting, idx) => ( + toggleFavorite(setting.key)} + onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })} + onReset={() => resetSettingMutation.mutate(setting.key)} + isUpdating={updateSettingMutation.isPending} + isResetting={resetSettingMutation.isPending} + isLast={idx === category.settings.length - 1} + /> + ))} +
); })} - - {categories.length === 0 && ( -
-

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

-
- )}
); } diff --git a/src/components/admin/SettingsTableRow.tsx b/src/components/admin/SettingsTableRow.tsx new file mode 100644 index 0000000..b25f893 --- /dev/null +++ b/src/components/admin/SettingsTableRow.tsx @@ -0,0 +1,162 @@ +import { useTranslation } from 'react-i18next'; +import { SettingDefinition } from '../../api/adminSettings'; +import { cn } from '../../lib/utils'; +import { StarIcon, LockIcon, RefreshIcon } from './icons'; +import { SettingInput } from './SettingInput'; +import { Toggle } from './Toggle'; +import { formatSettingKey, stripHtml } from './utils'; + +interface SettingsTableRowProps { + setting: SettingDefinition; + isFavorite: boolean; + onToggleFavorite: () => void; + onUpdate: (value: string) => void; + onReset: () => void; + isUpdating?: boolean; + isResetting?: boolean; + isLast?: boolean; + className?: string; +} + +export function SettingsTableRow({ + setting, + isFavorite, + onToggleFavorite, + onUpdate, + onReset, + isUpdating, + isResetting, + isLast, + className, +}: SettingsTableRowProps) { + const { t } = useTranslation(); + + const formattedKey = formatSettingKey(setting.name || setting.key); + const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + const description = setting.hint?.description ? stripHtml(setting.hint.description) : null; + const isModified = setting.has_override; + const isBool = setting.type === 'bool'; + const boolChecked = setting.current === true || setting.current === 'true'; + + const isLongValue = (() => { + const val = String(setting.current ?? ''); + const key = setting.key.toLowerCase(); + return ( + val.length > 50 || + val.includes('\n') || + val.startsWith('[') || + val.startsWith('{') || + key.includes('_items') || + key.includes('_config') || + key.includes('_keywords') || + key.includes('_template') || + key.includes('_packages') + ); + })(); + + return ( +
+
+ {/* Left side: name, badges, key */} +
+ {/* Name + badges row */} +
+ {displayName} + + {isModified && ( + + {t('admin.settings.modified')} + + )} + + {setting.has_override && !setting.read_only && ( + + {t('admin.settings.badgeDb')} + + )} + + {setting.read_only && ( + + {t('admin.settings.badgeEnv')} + + + )} +
+ + {/* Setting key */} +
+ {setting.key} +
+ + {/* Description for long values */} + {isLongValue && description && ( +

{description}

+ )} +
+ + {/* Right side: control + action buttons */} +
+ {setting.read_only ? ( + + {String(setting.current ?? '-')} + + ) : isBool ? ( + onUpdate(boolChecked ? 'false' : 'true')} + disabled={isUpdating} + aria-label={displayName} + /> + ) : ( +
+ +
+ )} + + {/* Reset button -- hover-reveal when has_override */} + {isModified && !setting.read_only && ( + + )} + + {/* Favorite button -- visible if favorited, hover-reveal otherwise */} + +
+
+
+ ); +} diff --git a/src/components/admin/SettingsTreeSidebar.tsx b/src/components/admin/SettingsTreeSidebar.tsx new file mode 100644 index 0000000..68ac48e --- /dev/null +++ b/src/components/admin/SettingsTreeSidebar.tsx @@ -0,0 +1,322 @@ +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SETTINGS_TREE } from './constants'; +import { StarIcon, SearchIcon, CloseIcon, ChevronDownIcon } from './icons'; +import { SettingDefinition } from '../../api/adminSettings'; +import { formatSettingKey } from './utils'; +import { cn } from '../../lib/utils'; + +interface SettingsTreeSidebarProps { + activeSection: string; + onSectionChange: (sectionId: string) => void; + favoritesCount: number; + searchQuery: string; + onSearchChange: (query: string) => void; + allSettings?: SettingDefinition[]; + onSelectSetting?: (setting: SettingDefinition) => void; + className?: string; +} + +export function SettingsTreeSidebar({ + activeSection, + onSectionChange, + favoritesCount, + searchQuery, + onSearchChange, + allSettings, + onSelectSetting, + className, +}: SettingsTreeSidebarProps) { + const { t } = useTranslation(); + const [expandedGroup, setExpandedGroup] = useState(null); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const searchContainerRef = useRef(null); + const inputRef = useRef(null); + + // Auto-expand the group containing the active section + useEffect(() => { + for (const group of SETTINGS_TREE.groups) { + if (group.children.some((child) => child.id === activeSection)) { + setExpandedGroup(group.id); + return; + } + } + }, [activeSection]); + + // Filter settings for autocomplete + const suggestions = + searchQuery.trim() && allSettings + ? allSettings + .filter((s) => { + const q = searchQuery.toLowerCase().trim(); + if (s.key.toLowerCase().includes(q)) return true; + if (s.name?.toLowerCase().includes(q)) return true; + const formattedKey = formatSettingKey(s.name || s.key); + const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + if (translatedName.toLowerCase().includes(q)) return true; + if (s.hint?.description?.toLowerCase().includes(q)) return true; + return false; + }) + .slice(0, 8) + : []; + + // Close dropdown on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) { + setIsSearchOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Reset highlighted index when suggestions change + useEffect(() => { + setHighlightedIndex(0); + }, [suggestions.length, searchQuery]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isSearchOpen || suggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((i) => (i + 1) % suggestions.length); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((i) => (i - 1 + suggestions.length) % suggestions.length); + break; + case 'Enter': + e.preventDefault(); + if (suggestions[highlightedIndex]) { + handleSelectSuggestion(suggestions[highlightedIndex]); + } + break; + case 'Escape': + setIsSearchOpen(false); + inputRef.current?.blur(); + break; + } + }; + + const handleSelectSuggestion = (setting: SettingDefinition) => { + setIsSearchOpen(false); + onSearchChange(setting.name || setting.key); + onSelectSetting?.(setting); + }; + + const getSettingDisplayName = (setting: SettingDefinition) => { + const formattedKey = formatSettingKey(setting.name || setting.key); + return t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + }; + + const handleGroupToggle = (groupId: string) => { + if (expandedGroup === groupId) { + setExpandedGroup(null); + } else { + setExpandedGroup(groupId); + // Auto-select first child when expanding + const group = SETTINGS_TREE.groups.find((g) => g.id === groupId); + if (group && group.children.length > 0) { + onSectionChange(group.children[0].id); + } + } + }; + + const isGroupActive = (groupId: string) => { + const group = SETTINGS_TREE.groups.find((g) => g.id === groupId); + return group?.children.some((child) => child.id === activeSection) ?? false; + }; + + // Special items excluding favorites (favorites is rendered separately) + const customizationItems = SETTINGS_TREE.specialItems.filter((item) => item.id !== 'favorites'); + + return ( + + ); +} diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index 812f33f..356de38 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -1,111 +1,160 @@ import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'; -// Menu item types -export interface MenuItem { +// Tree sidebar types +export interface TreeSubItem { id: string; + categories: string[]; +} + +export interface TreeGroup { + id: string; + icon: string; + children: TreeSubItem[]; +} + +export interface SpecialItem { + id: string; + icon?: string; iconType?: 'star' | null; - categories?: string[]; } -export interface MenuSection { - id: string; - items: MenuItem[]; +export interface SettingsTreeConfig { + specialItems: SpecialItem[]; + groups: TreeGroup[]; } -// Sidebar menu configuration -export const MENU_SECTIONS: MenuSection[] = [ - { - id: 'main', - items: [ - { id: 'favorites', iconType: 'star' }, - { id: 'branding', iconType: null }, - { id: 'theme', iconType: null }, - { id: 'analytics', iconType: null }, - { id: 'buttons', iconType: null }, - ], - }, - { - id: 'settings', - items: [ - { - id: 'payments', - iconType: null, - categories: [ - 'PAYMENT', - 'PAYMENT_VERIFICATION', - 'YOOKASSA', - 'CRYPTOBOT', - 'HELEKET', - 'PLATEGA', - 'TRIBUTE', - 'MULENPAY', - 'PAL24', - 'WATA', - 'TELEGRAM', - ], - }, - { - id: 'subscriptions', - iconType: null, - categories: [ - 'SUBSCRIPTIONS_CORE', - 'SIMPLE_SUBSCRIPTION', - 'PERIODS', - 'SUBSCRIPTION_PRICES', - 'TRAFFIC', - 'TRAFFIC_PACKAGES', - 'TRIAL', - 'AUTOPAY', - ], - }, - { - id: 'interface', - iconType: null, - categories: [ - 'INTERFACE', - 'INTERFACE_BRANDING', - 'INTERFACE_SUBSCRIPTION', - 'CONNECT_BUTTON', - 'MINIAPP', - 'TELEGRAM_WIDGET', - 'TELEGRAM_OIDC', - 'HAPP', - 'SKIP', - 'ADDITIONAL', - ], - }, - { - id: 'notifications', - iconType: null, - categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'], - }, - { id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] }, - { - id: 'system', - iconType: null, - categories: [ - 'CORE', - 'REMNAWAVE', - 'SERVER_STATUS', - 'MONITORING', - 'MAINTENANCE', - 'BACKUP', - 'VERSION', - 'WEB_API', - 'WEBHOOK', - 'LOG', - 'DEBUG', - 'EXTERNAL_ADMIN', - ], - }, - { - id: 'users', - iconType: null, - categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'], - }, - ], - }, -]; +// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups +export const SETTINGS_TREE: SettingsTreeConfig = { + specialItems: [ + { id: 'favorites', iconType: 'star' }, + { id: 'branding', icon: '🎨' }, + { id: 'theme', icon: '🌈' }, + { id: 'analytics', icon: '📊' }, + { id: 'buttons', icon: '📱' }, + ], + groups: [ + { + id: 'payments', + icon: '💳', + children: [ + { id: 'payments_general', categories: ['PAYMENT', 'PAYMENT_VERIFICATION'] }, + { id: 'payments_stars', categories: ['TELEGRAM'] }, + { id: 'payments_yookassa', categories: ['YOOKASSA'] }, + { id: 'payments_cryptobot', categories: ['CRYPTOBOT'] }, + { id: 'payments_cloudpayments', categories: ['CLOUDPAYMENTS'] }, + { id: 'payments_freekassa', categories: ['FREEKASSA'] }, + { id: 'payments_kassa_ai', categories: ['KASSA_AI'] }, + { id: 'payments_platega', categories: ['PLATEGA'] }, + { id: 'payments_pal24', categories: ['PAL24'] }, + { id: 'payments_heleket', categories: ['HELEKET'] }, + { id: 'payments_mulenpay', categories: ['MULENPAY'] }, + { id: 'payments_tribute', categories: ['TRIBUTE'] }, + { id: 'payments_wata', categories: ['WATA'] }, + { id: 'payments_riopay', categories: ['RIOPAY'] }, + { id: 'payments_severpay', categories: ['SEVERPAY'] }, + ], + }, + { + id: 'subscriptions', + icon: '📦', + children: [ + { id: 'subs_core', categories: ['SUBSCRIPTIONS_CORE'] }, + { id: 'subs_trial', categories: ['TRIAL'] }, + { id: 'subs_pricing', categories: ['SUBSCRIPTION_PRICES'] }, + { id: 'subs_periods', categories: ['PERIODS'] }, + { id: 'subs_traffic', categories: ['TRAFFIC', 'TRAFFIC_PACKAGES'] }, + { id: 'subs_simple', categories: ['SIMPLE_SUBSCRIPTION'] }, + { id: 'subs_autopay', categories: ['AUTOPAY'] }, + ], + }, + { + id: 'interface', + icon: '🖥️', + children: [ + { + id: 'iface_general', + categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'], + }, + { id: 'iface_connect', categories: ['CONNECT_BUTTON'] }, + { id: 'iface_miniapp', categories: ['MINIAPP'] }, + { id: 'iface_happ', categories: ['HAPP'] }, + { id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] }, + { id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] }, + { id: 'iface_skip', categories: ['SKIP'] }, + { id: 'iface_additional', categories: ['ADDITIONAL'] }, + ], + }, + { + id: 'users', + icon: '👥', + children: [ + { id: 'users_support', categories: ['SUPPORT'] }, + { id: 'users_referral', categories: ['REFERRAL'] }, + { id: 'users_channel', categories: ['CHANNEL'] }, + { id: 'users_localization', categories: ['LOCALIZATION', 'TIMEZONE'] }, + { id: 'users_moderation', categories: ['MODERATION', 'BAN_NOTIFICATIONS'] }, + ], + }, + { + id: 'notifications', + icon: '🔔', + children: [ + { id: 'notif_user', categories: ['NOTIFICATIONS', 'WEBHOOK_NOTIFICATIONS'] }, + { id: 'notif_admin', categories: ['ADMIN_NOTIFICATIONS'] }, + { id: 'notif_reports', categories: ['ADMIN_REPORTS'] }, + ], + }, + { + id: 'database', + icon: '🗄️', + children: [ + { id: 'db_general', categories: ['DATABASE'] }, + { id: 'db_postgres', categories: ['POSTGRES'] }, + { id: 'db_sqlite', categories: ['SQLITE'] }, + { id: 'db_redis', categories: ['REDIS'] }, + ], + }, + { + id: 'system', + icon: '⚙️', + children: [ + { id: 'sys_core', categories: ['CORE', 'DEBUG'] }, + { id: 'sys_remnawave', categories: ['REMNAWAVE'] }, + { id: 'sys_webapi', categories: ['WEB_API', 'EXTERNAL_ADMIN'] }, + { id: 'sys_webhook', categories: ['WEBHOOK'] }, + { id: 'sys_server', categories: ['SERVER_STATUS'] }, + { id: 'sys_monitoring', categories: ['MONITORING'] }, + { id: 'sys_maintenance', categories: ['MAINTENANCE'] }, + { id: 'sys_backup', categories: ['BACKUP'] }, + { id: 'sys_version', categories: ['VERSION'] }, + { id: 'sys_logging', categories: ['LOG'] }, + ], + }, + ], +}; + +// Helper: find which group and sub-item a backend category key belongs to +export function findTreeLocation( + categoryKey: string, +): { groupId: string; subItemId: string } | null { + for (const group of SETTINGS_TREE.groups) { + for (const child of group.children) { + if (child.categories.includes(categoryKey)) { + return { groupId: group.id, subItemId: child.id }; + } + } + } + return null; +} + +// Helper: get all backend category keys for a given sub-item id +export function getCategoriesForSubItem(subItemId: string): string[] { + for (const group of SETTINGS_TREE.groups) { + const child = group.children.find((c) => c.id === subItemId); + if (child) return child.categories; + } + return []; +} // Theme preset type export interface ThemePreset { diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index a6a7d72..90c30ad 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -5,6 +5,7 @@ export * from './icons'; export * from './Toggle'; export * from './SettingInput'; export * from './SettingRow'; +export * from './SettingsTableRow'; export * from './AnalyticsTab'; export * from './BrandingTab'; export * from './ButtonsTab'; @@ -13,6 +14,8 @@ export * from './FavoritesTab'; export * from './SettingsTab'; export * from './SettingsMobileTabs'; export * from './SettingsSearch'; +export * from './SettingsTreeSidebar'; +export * from './QuickToggles'; export * from './LocaleTabs'; export * from './LocalizedInput'; diff --git a/src/locales/en.json b/src/locales/en.json index 99893f7..f0f2061 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1836,8 +1836,11 @@ "title": "System Settings", "allCategories": "All categories", "noSettings": "No settings found", + "quickToggles": "Quick Toggles", "modified": "Modified", "readOnly": "Read only", + "badgeDb": "DB", + "badgeEnv": "ENV", "reset": "Reset", "categoriesCount": "categories", "settingsCount": "settings", @@ -1910,6 +1913,84 @@ "quickPresets": "Quick presets", "resetAllColors": "Reset all colors", "statusColors": "Status colors", + "favorites": "Favorites", + "branding": "Branding", + "theme": "Theme", + "payments": "Payments", + "subscriptions": "Subscriptions", + "interface": "Interface", + "notifications": "Notifications", + "database": "Database", + "system": "System", + "users": "Users", + "customization": "Customization", + "settingsLabel": "Settings", + "backToAdmin": "Back to admin", + "totalCount": "{{count}} total", + "modifiedCount": "{{count}} modified", + "groups": { + "payments": "Payments", + "subscriptions": "Subscriptions", + "interface": "Interface", + "users": "Users", + "notifications": "Notifications", + "database": "Database", + "system": "System" + }, + "tree": { + "payments_general": "General", + "payments_stars": "Telegram Stars", + "payments_yookassa": "YooKassa", + "payments_cryptobot": "CryptoBot", + "payments_cloudpayments": "CloudPayments", + "payments_freekassa": "FreeKassa", + "payments_kassa_ai": "Kassa AI", + "payments_platega": "Platega", + "payments_pal24": "PAL24", + "payments_heleket": "Heleket", + "payments_mulenpay": "MulenPay", + "payments_tribute": "Tribute", + "payments_wata": "Wata", + "payments_riopay": "RioPay", + "payments_severpay": "SeverPay", + "subs_core": "Core", + "subs_trial": "Trial", + "subs_pricing": "Pricing", + "subs_periods": "Periods", + "subs_traffic": "Traffic", + "subs_simple": "Simple subscription", + "subs_autopay": "Auto-pay", + "iface_general": "General", + "iface_connect": "Connect button", + "iface_miniapp": "MiniApp", + "iface_happ": "HAPP", + "iface_widget": "Login Widget", + "iface_oidc": "OIDC", + "iface_skip": "Skip steps", + "iface_additional": "Additional", + "users_support": "Support", + "users_referral": "Referral", + "users_channel": "Channel", + "users_localization": "Localization", + "users_moderation": "Moderation", + "notif_user": "User notifications", + "notif_admin": "Admin notifications", + "notif_reports": "Reports", + "db_general": "General", + "db_postgres": "PostgreSQL", + "db_sqlite": "SQLite", + "db_redis": "Redis", + "sys_core": "Core & Debug", + "sys_remnawave": "RemnaWave", + "sys_webapi": "Web API", + "sys_webhook": "Webhook", + "sys_server": "Server status", + "sys_monitoring": "Monitoring", + "sys_maintenance": "Maintenance", + "sys_backup": "Backup", + "sys_version": "Version", + "sys_logging": "Logging" + }, "categories": { "TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_OIDC": "Telegram Login (OIDC)" diff --git a/src/locales/fa.json b/src/locales/fa.json index def8574..ad623d6 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1497,6 +1497,7 @@ "title": "تنظیمات سیستم", "allCategories": "همه دسته‌ها", "noSettings": "تنظیماتی یافت نشد", + "quickToggles": "تغییر سریع", "modified": "تغییر یافته", "readOnly": "فقط خواندنی", "reset": "بازنشانی", @@ -1571,6 +1572,86 @@ "quickPresets": "پیش‌تنظیم‌های سریع", "resetAllColors": "بازنشانی همه رنگ‌ها", "statusColors": "رنگ‌های وضعیت", + "favorites": "علاقه‌مندی‌ها", + "branding": "برندسازی", + "theme": "پوسته", + "payments": "پرداخت‌ها", + "subscriptions": "اشتراک‌ها", + "interface": "رابط کاربری", + "notifications": "اعلان‌ها", + "database": "پایگاه داده", + "system": "سیستم", + "users": "کاربران", + "customization": "سفارشی‌سازی", + "settingsLabel": "تنظیمات", + "backToAdmin": "بازگشت به مدیریت", + "totalCount": "{{count}} مورد", + "modifiedCount": "{{count}} تغییر یافته", + "badgeDb": "پایگاه داده", + "badgeEnv": "متغیر محیطی", + "groups": { + "payments": "پرداخت‌ها", + "subscriptions": "اشتراک‌ها", + "interface": "رابط کاربری", + "users": "کاربران", + "notifications": "اعلان‌ها", + "database": "پایگاه داده", + "system": "سیستم" + }, + "tree": { + "payments_general": "عمومی", + "payments_stars": "Telegram Stars", + "payments_yookassa": "YooKassa", + "payments_cryptobot": "CryptoBot", + "payments_cloudpayments": "CloudPayments", + "payments_freekassa": "FreeKassa", + "payments_kassa_ai": "Kassa AI", + "payments_platega": "Platega", + "payments_pal24": "PAL24", + "payments_heleket": "Heleket", + "payments_mulenpay": "MulenPay", + "payments_tribute": "Tribute", + "payments_wata": "Wata", + "payments_riopay": "RioPay", + "payments_severpay": "SeverPay", + "subs_core": "هسته", + "subs_trial": "آزمایشی", + "subs_pricing": "قیمت‌گذاری", + "subs_periods": "دوره‌ها", + "subs_traffic": "ترافیک", + "subs_simple": "اشتراک ساده", + "subs_autopay": "پرداخت خودکار", + "iface_general": "عمومی", + "iface_connect": "دکمه اتصال", + "iface_miniapp": "MiniApp", + "iface_happ": "HAPP", + "iface_widget": "ویجت ورود", + "iface_oidc": "OIDC", + "iface_skip": "رد کردن مراحل", + "iface_additional": "اضافی", + "users_support": "پشتیبانی", + "users_referral": "معرفی", + "users_channel": "کانال", + "users_localization": "بومی‌سازی", + "users_moderation": "مدیریت محتوا", + "notif_user": "اعلان‌های کاربر", + "notif_admin": "اعلان‌های مدیر", + "notif_reports": "گزارش‌ها", + "db_general": "عمومی", + "db_postgres": "PostgreSQL", + "db_sqlite": "SQLite", + "db_redis": "Redis", + "sys_core": "هسته و اشکال‌زدایی", + "sys_remnawave": "RemnaWave", + "sys_webapi": "Web API", + "sys_webhook": "Webhook", + "sys_server": "وضعیت سرور", + "sys_monitoring": "نظارت", + "sys_maintenance": "نگهداری", + "sys_backup": "پشتیبان‌گیری", + "sys_version": "نسخه", + "sys_logging": "لاگ‌ها" + }, "categories": { "TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_OIDC": "Telegram Login (OIDC)" diff --git a/src/locales/ru.json b/src/locales/ru.json index adfef96..5046425 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1861,8 +1861,11 @@ "title": "Настройки системы", "allCategories": "Все категории", "noSettings": "Настройки не найдены", + "quickToggles": "Быстрые переключатели", "modified": "Изменено", "readOnly": "Только чтение", + "badgeDb": "БД", + "badgeEnv": "ENV", "reset": "Сбросить", "categoriesCount": "категорий", "settingsCount": "настроек", @@ -1936,6 +1939,74 @@ "googleLabelPlaceholder": "Например: AbCdEfGhIjKl", "googleLabelHint": "Метка конверсии для отслеживания событий", "analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.", + "customization": "Кастомизация", + "settingsLabel": "Настройки", + "backToAdmin": "Назад к админке", + "totalCount": "{{count}} всего", + "modifiedCount": "{{count}} изменено", + "groups": { + "payments": "Платежи", + "subscriptions": "Подписки", + "interface": "Интерфейс", + "users": "Пользователи", + "notifications": "Уведомления", + "database": "База данных", + "system": "Система" + }, + "tree": { + "payments_general": "Основные", + "payments_stars": "Telegram Stars", + "payments_yookassa": "YooKassa", + "payments_cryptobot": "CryptoBot", + "payments_cloudpayments": "CloudPayments", + "payments_freekassa": "FreeKassa", + "payments_kassa_ai": "Kassa AI", + "payments_platega": "Platega", + "payments_pal24": "PAL24", + "payments_heleket": "Heleket", + "payments_mulenpay": "MulenPay", + "payments_tribute": "Tribute", + "payments_wata": "Wata", + "payments_riopay": "RioPay", + "payments_severpay": "SeverPay", + "subs_core": "Основные", + "subs_trial": "Пробный период", + "subs_pricing": "Цены", + "subs_periods": "Периоды", + "subs_traffic": "Трафик", + "subs_simple": "Простая подписка", + "subs_autopay": "Автоплатёж", + "iface_general": "Основные", + "iface_connect": "Кнопка подключения", + "iface_miniapp": "MiniApp", + "iface_happ": "HAPP", + "iface_widget": "Виджет входа", + "iface_oidc": "OIDC", + "iface_skip": "Пропуск шагов", + "iface_additional": "Дополнительно", + "users_support": "Поддержка", + "users_referral": "Реферальная система", + "users_channel": "Канал", + "users_localization": "Локализация", + "users_moderation": "Модерация", + "notif_user": "Уведомления пользователей", + "notif_admin": "Уведомления админов", + "notif_reports": "Отчёты", + "db_general": "Основные", + "db_postgres": "PostgreSQL", + "db_sqlite": "SQLite", + "db_redis": "Redis", + "sys_core": "Ядро и отладка", + "sys_remnawave": "RemnaWave", + "sys_webapi": "Web API", + "sys_webhook": "Webhook", + "sys_server": "Статус сервера", + "sys_monitoring": "Мониторинг", + "sys_maintenance": "Обслуживание", + "sys_backup": "Бэкап", + "sys_version": "Версия", + "sys_logging": "Логи" + }, "presets": { "standard": "Стандарт", "ocean": "Океан", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2b0e3fd..478f57b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1535,6 +1535,7 @@ "title": "系统设置", "allCategories": "所有分类", "noSettings": "未找到设置", + "quickToggles": "快速开关", "modified": "已修改", "readOnly": "只读", "reset": "重置", @@ -1609,6 +1610,86 @@ "quickPresets": "快速预设", "resetAllColors": "重置所有颜色", "statusColors": "状态颜色", + "favorites": "收藏", + "branding": "品牌", + "theme": "主题", + "payments": "支付", + "subscriptions": "订阅", + "interface": "界面", + "notifications": "通知", + "database": "数据库", + "system": "系统", + "users": "用户", + "customization": "自定义", + "settingsLabel": "设置", + "backToAdmin": "返回管理面板", + "totalCount": "共 {{count}} 项", + "modifiedCount": "已修改 {{count}} 项", + "badgeDb": "数据库", + "badgeEnv": "环境变量", + "groups": { + "payments": "支付", + "subscriptions": "订阅", + "interface": "界面", + "users": "用户", + "notifications": "通知", + "database": "数据库", + "system": "系统" + }, + "tree": { + "payments_general": "通用", + "payments_stars": "Telegram Stars", + "payments_yookassa": "YooKassa", + "payments_cryptobot": "CryptoBot", + "payments_cloudpayments": "CloudPayments", + "payments_freekassa": "FreeKassa", + "payments_kassa_ai": "Kassa AI", + "payments_platega": "Platega", + "payments_pal24": "PAL24", + "payments_heleket": "Heleket", + "payments_mulenpay": "MulenPay", + "payments_tribute": "Tribute", + "payments_wata": "Wata", + "payments_riopay": "RioPay", + "payments_severpay": "SeverPay", + "subs_core": "核心", + "subs_trial": "试用", + "subs_pricing": "定价", + "subs_periods": "周期", + "subs_traffic": "流量", + "subs_simple": "简单订阅", + "subs_autopay": "自动支付", + "iface_general": "通用", + "iface_connect": "连接按钮", + "iface_miniapp": "MiniApp", + "iface_happ": "HAPP", + "iface_widget": "登录小部件", + "iface_oidc": "OIDC", + "iface_skip": "跳过步骤", + "iface_additional": "附加", + "users_support": "支持", + "users_referral": "推荐", + "users_channel": "频道", + "users_localization": "本地化", + "users_moderation": "审核", + "notif_user": "用户通知", + "notif_admin": "管理员通知", + "notif_reports": "报告", + "db_general": "通用", + "db_postgres": "PostgreSQL", + "db_sqlite": "SQLite", + "db_redis": "Redis", + "sys_core": "核心与调试", + "sys_remnawave": "RemnaWave", + "sys_webapi": "Web API", + "sys_webhook": "Webhook", + "sys_server": "服务器状态", + "sys_monitoring": "监控", + "sys_maintenance": "维护", + "sys_backup": "备份", + "sys_version": "版本", + "sys_logging": "日志" + }, "categories": { "TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_OIDC": "Telegram Login (OIDC)" diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 7525a96..955b9f4 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { adminSettingsApi, SettingDefinition } from '../api/adminSettings'; import { themeColorsApi } from '../api/themeColors'; import { useFavoriteSettings } from '../hooks/useFavoriteSettings'; -import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin'; +import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin'; import { usePlatform } from '../platform/hooks/usePlatform'; import { AnalyticsTab } from '../components/admin/AnalyticsTab'; import { BrandingTab } from '../components/admin/BrandingTab'; @@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab'; import { ThemeTab } from '../components/admin/ThemeTab'; import { FavoritesTab } from '../components/admin/FavoritesTab'; import { SettingsTab } from '../components/admin/SettingsTab'; +import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar'; import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs'; -import { - SettingsSearch, - SettingsSearchMobile, - SettingsSearchResults, -} from '../components/admin/SettingsSearch'; +import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'; // BackIcon const BackIcon = () => ( @@ -33,17 +30,18 @@ const BackIcon = () => ( ); -// Find section ID by category key -function findSectionByCategory(categoryKey: string): string | null { - for (const section of MENU_SECTIONS) { - for (const item of section.items) { - if (item.categories?.includes(categoryKey)) { - return item.id; - } - } - } - return null; -} +// ChevronRight for breadcrumbs +const ChevronRightIcon = () => ( + + + +); export default function AdminSettings() { const { t } = useTranslation(); @@ -73,23 +71,29 @@ export default function AdminSettings() { queryFn: () => adminSettingsApi.getSettings(), }); - // Get current menu item configuration - const currentMenuItem = useMemo(() => { - for (const section of MENU_SECTIONS) { - const item = section.items.find((i: MenuItem) => i.id === activeSection); - if (item) return item; + // Find active tree info (group + child for tree sub-items) + // No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial + let activeTreeInfo: { + group: (typeof SETTINGS_TREE.groups)[number]; + child: (typeof SETTINGS_TREE.groups)[number]['children'][number]; + } | null = null; + for (const group of SETTINGS_TREE.groups) { + const child = group.children.find((c) => c.id === activeSection); + if (child) { + activeTreeInfo = { group, child }; + break; } - return null; - }, [activeSection]); + } - // Get categories for current section + // Get categories for current sub-item const currentCategories = useMemo(() => { - if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return []; + if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return []; + const categoryKeys = activeTreeInfo.child.categories; const categoryMap = new Map(); for (const setting of allSettings) { - if (currentMenuItem.categories.includes(setting.category.key)) { + if (categoryKeys.includes(setting.category.key)) { if (!categoryMap.has(setting.category.key)) { categoryMap.set(setting.category.key, []); } @@ -102,7 +106,7 @@ export default function AdminSettings() { label: t(`admin.settings.categories.${key}`, key), settings, })); - }, [currentMenuItem, allSettings, t]); + }, [activeTreeInfo, allSettings, t]); // Filter settings for search - GLOBAL search across all settings const filteredSettings = useMemo(() => { @@ -112,24 +116,14 @@ export default function AdminSettings() { if (!q) return []; return allSettings.filter((s: SettingDefinition) => { - // Search by key if (s.key.toLowerCase().includes(q)) return true; - - // Search by original name if (s.name?.toLowerCase().includes(q)) return true; - - // Search by translated name const formattedKey = formatSettingKey(s.name || s.key); const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); if (translatedName.toLowerCase().includes(q)) return true; - - // Search by description if (s.hint?.description?.toLowerCase().includes(q)) return true; - - // Search by category const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key); if (categoryLabel.toLowerCase().includes(q)) return true; - return false; }); }, [allSettings, searchQuery, t]); @@ -140,19 +134,42 @@ export default function AdminSettings() { return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key)); }, [allSettings, favorites]); + // Count of modified settings + const modifiedCount = useMemo(() => { + if (!allSettings || !Array.isArray(allSettings)) return 0; + return allSettings.filter((s: SettingDefinition) => s.has_override).length; + }, [allSettings]); + + // Total settings count + const totalCount = useMemo(() => { + if (!allSettings || !Array.isArray(allSettings)) return 0; + return allSettings.length; + }, [allSettings]); + // Handle setting selection from autocomplete const handleSelectSetting = useCallback( (setting: SettingDefinition) => { - const sectionId = findSectionByCategory(setting.category.key); - if (sectionId) { - setActiveSection(sectionId); + const location = findTreeLocation(setting.category.key); + if (location) { + setActiveSection(location.subItemId); } - // Set search to setting key to filter to just this setting setSearchQuery(setting.key); }, [setActiveSection, setSearchQuery], ); + // Get the display title for the current section + const sectionTitle = useMemo(() => { + // Special items + const specialItem = SETTINGS_TREE.specialItems.find((item) => item.id === activeSection); + if (specialItem) return t(`admin.settings.${specialItem.id}`); + + // Tree sub-items + if (activeTreeInfo) return t(`admin.settings.tree.${activeTreeInfo.child.id}`); + + return t('admin.settings.title'); + }, [activeSection, activeTreeInfo, t]); + // Render content based on active section const renderContent = () => { // If searching, always show search results regardless of active section @@ -186,17 +203,7 @@ export default function AdminSettings() { /> ); default: - if ( - [ - 'payments', - 'subscriptions', - 'interface', - 'notifications', - 'database', - 'system', - 'users', - ].includes(activeSection) - ) { + if (activeTreeInfo) { return ( {/* Fixed Sidebar */} -
+
{/* Show back button only on web, not in Telegram Mini App */} @@ -241,6 +248,7 @@ export default function AdminSettings() { @@ -248,67 +256,52 @@ export default function AdminSettings() {

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

- +
{/* Scrollable Content */}
+ {/* Breadcrumb for tree sub-items */} + {activeTreeInfo && !searchQuery.trim() && ( +
+ + + + {t(`admin.settings.tree.${activeTreeInfo.child.id}`)} + +
+ )} + + {/* Title + count badges */}
-

- {t(`admin.settings.${activeSection}`)} -

-
- +

{sectionTitle}

+ {totalCount > 0 && !searchQuery.trim() && activeTreeInfo && ( +
+ + {t('admin.settings.totalCount', { count: totalCount })} + + {modifiedCount > 0 && ( + + {t('admin.settings.modifiedCount', { count: modifiedCount })} + + )} +
+ )}
+ {renderContent()}