feat: redesign admin settings with tree navigation and compact layout

- Replace flat sidebar with collapsible tree groups (7 groups, 52 sub-items)
- Map all 61 backend category keys to sidebar navigation
- Add QuickToggles panel for boolean settings
- Replace card-style SettingRow with compact SettingsTableRow
- Add breadcrumb navigation for tree sub-items
- Add source badges (modified/DB/ENV) on settings
- Implement two-level mobile navigation with group chips
- Add i18n translations for all new keys (en, ru, zh, fa)
This commit is contained in:
Fringg
2026-03-29 02:38:52 +03:00
parent 88c93e2b5f
commit 21813ef82e
13 changed files with 1334 additions and 344 deletions

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'; import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { StarIcon } from './icons'; import { StarIcon } from './icons';
import { SettingRow } from './SettingRow'; import { SettingsTableRow } from './SettingsTableRow';
interface FavoritesTabProps { interface FavoritesTabProps {
settings: SettingDefinition[]; settings: SettingDefinition[];
@@ -42,9 +42,9 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
} }
return ( return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="overflow-hidden rounded-xl border border-dark-700/40">
{settings.map((setting) => ( {settings.map((setting, idx) => (
<SettingRow <SettingsTableRow
key={setting.key} key={setting.key}
setting={setting} setting={setting}
isFavorite={isFavorite(setting.key)} isFavorite={isFavorite(setting.key)}
@@ -53,6 +53,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
onReset={() => resetSettingMutation.mutate(setting.key)} onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending} isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending} isResetting={resetSettingMutation.isPending}
isLast={idx === settings.length - 1}
/> />
))} ))}
</div> </div>

View File

@@ -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 (
<div
className={cn('rounded-xl border border-dark-700/40 bg-dark-800/30 px-3 py-2.5', className)}
>
<span className="mb-2 block text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.quickToggles')}
</span>
<div className="flex flex-wrap gap-2">
{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 (
<button
key={setting.key}
type="button"
onClick={() => onUpdate(setting.key, isOn ? 'false' : 'true')}
disabled={disabled}
className={cn(
'flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-all',
isOn
? 'border-success-500/20 bg-success-500/[0.08] text-dark-100'
: 'border-dark-600/50 bg-dark-700/20 text-dark-400',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:opacity-80',
)}
>
{/* Mini toggle indicator */}
<div
className={cn(
'relative h-3.5 w-6 flex-shrink-0 rounded-full transition-colors',
isOn ? 'bg-success-500' : 'bg-dark-600',
)}
>
<div
className={cn(
'absolute left-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-white transition-transform duration-200',
isOn ? 'translate-x-2.5' : 'translate-x-0',
)}
/>
</div>
<span className="max-w-[120px] truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useRef, useEffect } from 'react'; import { useRef, useEffect, useState } from 'react';
import { MENU_SECTIONS } from './constants'; import { SETTINGS_TREE } from './constants';
import { StarIcon } from './icons'; import { StarIcon } from './icons';
interface SettingsMobileTabsProps { interface SettingsMobileTabsProps {
@@ -17,6 +17,7 @@ export function SettingsMobileTabs({
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement>(null); const activeRef = useRef<HTMLButtonElement>(null);
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
// Scroll active tab into view // Scroll active tab into view
useEffect(() => { useEffect(() => {
@@ -26,53 +27,155 @@ export function SettingsMobileTabs({
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const activeRect = activeEl.getBoundingClientRect(); const activeRect = activeEl.getBoundingClientRect();
// Check if active element is not fully visible
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) { if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
} }
} }
}, [activeSection]); }, [activeSection]);
// Flatten all items from all sections // Auto-expand the group containing the active section
const allItems = MENU_SECTIONS.flatMap((section) => section.items); 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 ( return (
<div <div>
ref={scrollRef} {/* Level 1: Favorites + special items + group chips */}
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3" <div
style={{ WebkitOverflowScrolling: 'touch' }} ref={scrollRef}
> className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
{allItems.map((item) => { style={{ WebkitOverflowScrolling: 'touch' }}
const isActive = activeSection === item.id; >
const hasIcon = item.iconType === 'star'; {/* Favorites chip */}
<button
ref={isFavoritesActive ? activeRef : null}
onClick={() => {
setActiveSection('favorites');
setExpandedGroup(null);
}}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
isFavoritesActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
<StarIcon filled={isFavoritesActive} />
<span className="whitespace-nowrap">{t('admin.settings.favorites')}</span>
{favoritesCount > 0 && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs ${
isFavoritesActive
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
{favoritesCount}
</span>
)}
</button>
return ( {/* Special item chips (branding, theme, analytics, buttons) */}
<button {specialItems.map((item) => {
key={item.id} const isActive = isSpecialActive(item.id);
ref={isActive ? activeRef : null} return (
onClick={() => setActiveSection(item.id)} <button
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${ key={item.id}
isActive ref={isActive ? activeRef : null}
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30' onClick={() => {
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700' setActiveSection(item.id);
}`} setExpandedGroup(null);
> }}
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />} className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span> isActive
{item.id === 'favorites' && favoritesCount > 0 && ( ? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
<span : 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
className={`rounded-full px-1.5 py-0.5 text-xs ${ }`}
isActive >
? 'bg-accent-500/20 text-accent-400' {item.icon && <span className="text-sm">{item.icon}</span>}
: 'bg-warning-500/20 text-warning-400' <span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
}`} </button>
> );
{favoritesCount} })}
</span>
)} {/* Group chips */}
</button> {SETTINGS_TREE.groups.map((group) => {
); const hasActiveChild = isGroupActive(group.id);
})} const isExpanded = expandedGroup === group.id;
return (
<button
key={group.id}
ref={hasActiveChild ? activeRef : null}
onClick={() => handleGroupTap(group.id)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
hasActiveChild || isExpanded
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
<span className="text-sm">{group.icon}</span>
<span className="whitespace-nowrap">{t(`admin.settings.groups.${group.id}`)}</span>
</button>
);
})}
</div>
{/* Level 2: Sub-item chips (shown when a group is expanded) */}
{expandedGroup && (
<div
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 pb-2"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{SETTINGS_TREE.groups
.find((g) => g.id === expandedGroup)
?.children.map((child) => {
const isActive = activeSection === child.id;
return (
<button
key={child.id}
onClick={() => setActiveSection(child.id)}
className={`shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400 ring-1 ring-accent-500/20'
: 'bg-dark-800/30 text-dark-500 active:bg-dark-700'
}`}
>
<span className="whitespace-nowrap">{t(`admin.settings.tree.${child.id}`)}</span>
</button>
);
})}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'; import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { ChevronDownIcon } from './icons'; import { QuickToggles } from './QuickToggles';
import { SettingRow } from './SettingRow'; import { SettingsTableRow } from './SettingsTableRow';
interface CategoryGroup { interface CategoryGroup {
key: string; key: string;
@@ -29,20 +28,6 @@ export function SettingsTab({
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [expandedSections, setExpandedSections] = useState<Set<string>>(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({ const updateSettingMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => mutationFn: ({ key, value }: { key: string; value: string }) =>
adminSettingsApi.updateSetting(key, value), 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 (searchQuery) {
if (filteredSettings.length === 0) {
return (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="overflow-hidden rounded-xl border border-dark-700/40">
{filteredSettings.length === 0 ? ( {filteredSettings.map((setting, idx) => (
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center"> <SettingsTableRow
<p className="text-dark-400">{t('admin.settings.noSettings')}</p> key={setting.key}
</div> setting={setting}
) : ( isFavorite={isFavorite(setting.key)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> onToggleFavorite={() => toggleFavorite(setting.key)}
{filteredSettings.map((setting) => ( onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
<SettingRow onReset={() => resetSettingMutation.mutate(setting.key)}
key={setting.key} isUpdating={updateSettingMutation.isPending}
setting={setting} isResetting={resetSettingMutation.isPending}
isFavorite={isFavorite(setting.key)} isLast={idx === filteredSettings.length - 1}
onToggleFavorite={() => toggleFavorite(setting.key)} />
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })} ))}
onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
/>
))}
</div>
)}
</div> </div>
); );
} }
// Show accordion for subcategories // Normal mode: QuickToggles + settings by category
return ( const allCategorySettings = categories.flatMap((c) => c.settings);
<div className="space-y-3">
{categories.map((cat) => {
const isExpanded = expandedSections.has(cat.key);
return (
<div
key={cat.key}
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
>
{/* Accordion header */}
<button
onClick={() => toggleSection(cat.key)}
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
>
<div className="flex items-center gap-3">
<span className="font-medium text-dark-100">{cat.label}</span>
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
{cat.settings.length}
</span>
</div>
<div
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
{/* Accordion content */} if (allCategorySettings.length === 0) {
{isExpanded && ( return (
<div className="border-t border-dark-700/30 p-4 pt-0"> <div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2"> <p className="text-dark-400">{t('admin.settings.noSettings')}</p>
{cat.settings.map((setting) => ( </div>
<SettingRow );
key={setting.key} }
setting={setting}
isFavorite={isFavorite(setting.key)} return (
onToggleFavorite={() => toggleFavorite(setting.key)} <div>
onUpdate={(value) => <QuickToggles
updateSettingMutation.mutate({ key: setting.key, value }) settings={allCategorySettings}
} onUpdate={(key, value) => updateSettingMutation.mutate({ key, value })}
onReset={() => resetSettingMutation.mutate(setting.key)} disabled={updateSettingMutation.isPending}
isUpdating={updateSettingMutation.isPending} />
isResetting={resetSettingMutation.isPending} {categories.map((category) => {
/> if (category.settings.length === 0) return null;
))} return (
</div> <div key={category.key} className="mb-4">
{categories.length > 1 && (
<div className="mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold text-dark-200">{category.label}</h3>
<span className="text-xs text-dark-500">{category.settings.length}</span>
</div> </div>
)} )}
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{category.settings.map((setting, idx) => (
<SettingsTableRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(setting.key)}
onToggleFavorite={() => 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}
/>
))}
</div>
</div> </div>
); );
})} })}
{categories.length === 0 && (
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div>
)}
</div> </div>
); );
} }

View File

@@ -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 (
<div
className={cn(
'group px-4 py-3 transition-colors hover:bg-dark-800/40',
isModified && 'bg-warning-500/[0.02]',
!isLast && 'border-b border-dark-700/30',
className,
)}
>
<div className={cn(isLongValue ? 'space-y-3' : 'flex items-center gap-4')}>
{/* Left side: name, badges, key */}
<div className={cn('min-w-0', !isLongValue && 'flex-1')}>
{/* Name + badges row */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[13px] font-medium text-dark-100">{displayName}</span>
{isModified && (
<span className="rounded-full bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400">
{t('admin.settings.modified')}
</span>
)}
{setting.has_override && !setting.read_only && (
<span className="rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-sky-400">
{t('admin.settings.badgeDb')}
</span>
)}
{setting.read_only && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-400">
{t('admin.settings.badgeEnv')}
<LockIcon className="h-3 w-3" />
</span>
)}
</div>
{/* Setting key */}
<div className="mt-0.5">
<code className="font-mono text-[11px] text-dark-500">{setting.key}</code>
</div>
{/* Description for long values */}
{isLongValue && description && (
<p className="mt-1 text-xs leading-relaxed text-dark-400">{description}</p>
)}
</div>
{/* Right side: control + action buttons */}
<div className={cn('flex items-center gap-2', isLongValue ? 'w-full' : 'flex-shrink-0')}>
{setting.read_only ? (
<span className="max-w-[240px] truncate rounded bg-dark-700/30 px-3 py-1.5 font-mono text-xs text-dark-400">
{String(setting.current ?? '-')}
</span>
) : isBool ? (
<Toggle
checked={boolChecked}
onChange={() => onUpdate(boolChecked ? 'false' : 'true')}
disabled={isUpdating}
aria-label={displayName}
/>
) : (
<div className={cn(isLongValue && 'w-full')}>
<SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
</div>
)}
{/* Reset button -- hover-reveal when has_override */}
{isModified && !setting.read_only && (
<button
onClick={onReset}
disabled={isResetting}
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-hover:opacity-100"
title={t('admin.settings.reset')}
aria-label={t('admin.settings.reset')}
>
<RefreshIcon />
</button>
)}
{/* Favorite button -- visible if favorited, hover-reveal otherwise */}
<button
onClick={onToggleFavorite}
className={cn(
'flex-shrink-0 rounded-lg p-1.5 transition-all',
isFavorite
? 'text-warning-400 hover:bg-warning-500/15'
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100',
)}
title={
isFavorite
? t('admin.settings.removeFromFavorites')
: t('admin.settings.addToFavorites')
}
aria-label={
isFavorite
? t('admin.settings.removeFromFavorites')
: t('admin.settings.addToFavorites')
}
>
<StarIcon filled={isFavorite} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const searchContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<nav className={cn('flex flex-col', className)}>
{/* Search bar */}
<div ref={searchContainerRef} className="relative px-3 pb-2 pt-3">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => {
onSearchChange(e.target.value);
setIsSearchOpen(true);
}}
onFocus={() => setIsSearchOpen(true)}
onKeyDown={handleKeyDown}
placeholder={t('admin.settings.searchPlaceholder')}
className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-2 pl-9 pr-8 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon className="h-4 w-4" />
</div>
{searchQuery && (
<button
onClick={() => {
onSearchChange('');
setIsSearchOpen(false);
}}
className="absolute right-6 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
>
<CloseIcon className="h-4 w-4" />
</button>
)}
{/* Autocomplete dropdown */}
{isSearchOpen && suggestions.length > 0 && (
<div className="absolute left-3 right-3 top-full z-50 mt-1 max-h-72 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 py-1 shadow-xl">
{suggestions.map((setting, index) => (
<button
key={setting.key}
onClick={() => handleSelectSuggestion(setting)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
'flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors',
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50',
)}
>
<span className="truncate text-sm font-medium text-dark-100">
{getSettingDisplayName(setting)}
</span>
<span className="truncate text-xs text-dark-500">
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
</span>
</button>
))}
</div>
)}
</div>
{/* Favorites button */}
<div className="px-3 pb-1">
<button
onClick={() => onSectionChange('favorites')}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
activeSection === 'favorites'
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<StarIcon className="h-4 w-4" filled={activeSection === 'favorites'} />
<span className="text-sm font-medium">{t('admin.settings.favorites', 'Favorites')}</span>
{favoritesCount > 0 && (
<span
className={cn(
'ml-auto rounded-full px-2 py-0.5 text-xs',
activeSection === 'favorites'
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400',
)}
>
{favoritesCount}
</span>
)}
</button>
</div>
{/* Divider */}
<div className="mx-3 border-t border-dark-700/50" />
{/* Customization section label */}
<div className="px-6 pb-1 pt-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.customization', 'Customization')}
</span>
</div>
{/* Special items (branding, theme, analytics, buttons) */}
<div className="space-y-0.5 px-3 pb-1">
{customizationItems.map((item) => {
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
{item.icon && <span className="text-sm">{item.icon}</span>}
<span className="text-sm font-medium">{t(`admin.settings.${item.id}`, item.id)}</span>
</button>
);
})}
</div>
{/* Divider */}
<div className="mx-3 border-t border-dark-700/50" />
{/* Settings section label */}
<div className="px-6 pb-1 pt-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.settingsLabel', 'Settings')}
</span>
</div>
{/* Tree groups */}
<div className="space-y-0.5 px-3 pb-3">
{SETTINGS_TREE.groups.map((group) => {
const isExpanded = expandedGroup === group.id;
const hasActiveChild = isGroupActive(group.id);
return (
<div key={group.id}>
{/* Group header */}
<button
onClick={() => handleGroupToggle(group.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
hasActiveChild
? 'text-accent-300'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<span className="text-sm">{group.icon}</span>
<span className="flex-1 text-left text-sm font-medium">
{t(`admin.settings.groups.${group.id}`, group.id)}
</span>
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
isExpanded && 'rotate-180',
)}
/>
</button>
{/* Children */}
{isExpanded && (
<div className="relative ml-5 mt-0.5 space-y-0.5 border-l border-dark-700/50 pl-3">
{group.children.map((child) => {
const isActive = activeSection === child.id;
return (
<button
key={child.id}
onClick={() => onSectionChange(child.id)}
className={cn(
'flex w-full items-center rounded-lg px-3 py-1.5 text-left text-sm transition-all',
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
{t(`admin.settings.tree.${child.id}`, child.id)}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</nav>
);
}

View File

@@ -1,111 +1,160 @@
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'; import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
// Menu item types // Tree sidebar types
export interface MenuItem { export interface TreeSubItem {
id: string; id: string;
categories: string[];
}
export interface TreeGroup {
id: string;
icon: string;
children: TreeSubItem[];
}
export interface SpecialItem {
id: string;
icon?: string;
iconType?: 'star' | null; iconType?: 'star' | null;
categories?: string[];
} }
export interface MenuSection { export interface SettingsTreeConfig {
id: string; specialItems: SpecialItem[];
items: MenuItem[]; groups: TreeGroup[];
} }
// Sidebar menu configuration // Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
export const MENU_SECTIONS: MenuSection[] = [ export const SETTINGS_TREE: SettingsTreeConfig = {
{ specialItems: [
id: 'main', { id: 'favorites', iconType: 'star' },
items: [ { id: 'branding', icon: '🎨' },
{ id: 'favorites', iconType: 'star' }, { id: 'theme', icon: '🌈' },
{ id: 'branding', iconType: null }, { id: 'analytics', icon: '📊' },
{ id: 'theme', iconType: null }, { id: 'buttons', icon: '📱' },
{ id: 'analytics', iconType: null }, ],
{ id: 'buttons', iconType: null }, groups: [
], {
}, id: 'payments',
{ icon: '💳',
id: 'settings', children: [
items: [ { id: 'payments_general', categories: ['PAYMENT', 'PAYMENT_VERIFICATION'] },
{ { id: 'payments_stars', categories: ['TELEGRAM'] },
id: 'payments', { id: 'payments_yookassa', categories: ['YOOKASSA'] },
iconType: null, { id: 'payments_cryptobot', categories: ['CRYPTOBOT'] },
categories: [ { id: 'payments_cloudpayments', categories: ['CLOUDPAYMENTS'] },
'PAYMENT', { id: 'payments_freekassa', categories: ['FREEKASSA'] },
'PAYMENT_VERIFICATION', { id: 'payments_kassa_ai', categories: ['KASSA_AI'] },
'YOOKASSA', { id: 'payments_platega', categories: ['PLATEGA'] },
'CRYPTOBOT', { id: 'payments_pal24', categories: ['PAL24'] },
'HELEKET', { id: 'payments_heleket', categories: ['HELEKET'] },
'PLATEGA', { id: 'payments_mulenpay', categories: ['MULENPAY'] },
'TRIBUTE', { id: 'payments_tribute', categories: ['TRIBUTE'] },
'MULENPAY', { id: 'payments_wata', categories: ['WATA'] },
'PAL24', { id: 'payments_riopay', categories: ['RIOPAY'] },
'WATA', { id: 'payments_severpay', categories: ['SEVERPAY'] },
'TELEGRAM', ],
], },
}, {
{ id: 'subscriptions',
id: 'subscriptions', icon: '📦',
iconType: null, children: [
categories: [ { id: 'subs_core', categories: ['SUBSCRIPTIONS_CORE'] },
'SUBSCRIPTIONS_CORE', { id: 'subs_trial', categories: ['TRIAL'] },
'SIMPLE_SUBSCRIPTION', { id: 'subs_pricing', categories: ['SUBSCRIPTION_PRICES'] },
'PERIODS', { id: 'subs_periods', categories: ['PERIODS'] },
'SUBSCRIPTION_PRICES', { id: 'subs_traffic', categories: ['TRAFFIC', 'TRAFFIC_PACKAGES'] },
'TRAFFIC', { id: 'subs_simple', categories: ['SIMPLE_SUBSCRIPTION'] },
'TRAFFIC_PACKAGES', { id: 'subs_autopay', categories: ['AUTOPAY'] },
'TRIAL', ],
'AUTOPAY', },
], {
}, id: 'interface',
{ icon: '🖥️',
id: 'interface', children: [
iconType: null, {
categories: [ id: 'iface_general',
'INTERFACE', categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'],
'INTERFACE_BRANDING', },
'INTERFACE_SUBSCRIPTION', { id: 'iface_connect', categories: ['CONNECT_BUTTON'] },
'CONNECT_BUTTON', { id: 'iface_miniapp', categories: ['MINIAPP'] },
'MINIAPP', { id: 'iface_happ', categories: ['HAPP'] },
'TELEGRAM_WIDGET', { id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
'TELEGRAM_OIDC', { id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
'HAPP', { id: 'iface_skip', categories: ['SKIP'] },
'SKIP', { id: 'iface_additional', categories: ['ADDITIONAL'] },
'ADDITIONAL', ],
], },
}, {
{ id: 'users',
id: 'notifications', icon: '👥',
iconType: null, children: [
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'], { id: 'users_support', categories: ['SUPPORT'] },
}, { id: 'users_referral', categories: ['REFERRAL'] },
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] }, { id: 'users_channel', categories: ['CHANNEL'] },
{ { id: 'users_localization', categories: ['LOCALIZATION', 'TIMEZONE'] },
id: 'system', { id: 'users_moderation', categories: ['MODERATION', 'BAN_NOTIFICATIONS'] },
iconType: null, ],
categories: [ },
'CORE', {
'REMNAWAVE', id: 'notifications',
'SERVER_STATUS', icon: '🔔',
'MONITORING', children: [
'MAINTENANCE', { id: 'notif_user', categories: ['NOTIFICATIONS', 'WEBHOOK_NOTIFICATIONS'] },
'BACKUP', { id: 'notif_admin', categories: ['ADMIN_NOTIFICATIONS'] },
'VERSION', { id: 'notif_reports', categories: ['ADMIN_REPORTS'] },
'WEB_API', ],
'WEBHOOK', },
'LOG', {
'DEBUG', id: 'database',
'EXTERNAL_ADMIN', icon: '🗄️',
], children: [
}, { id: 'db_general', categories: ['DATABASE'] },
{ { id: 'db_postgres', categories: ['POSTGRES'] },
id: 'users', { id: 'db_sqlite', categories: ['SQLITE'] },
iconType: null, { id: 'db_redis', categories: ['REDIS'] },
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'], ],
}, },
], {
}, 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 // Theme preset type
export interface ThemePreset { export interface ThemePreset {

View File

@@ -5,6 +5,7 @@ export * from './icons';
export * from './Toggle'; export * from './Toggle';
export * from './SettingInput'; export * from './SettingInput';
export * from './SettingRow'; export * from './SettingRow';
export * from './SettingsTableRow';
export * from './AnalyticsTab'; export * from './AnalyticsTab';
export * from './BrandingTab'; export * from './BrandingTab';
export * from './ButtonsTab'; export * from './ButtonsTab';
@@ -13,6 +14,8 @@ export * from './FavoritesTab';
export * from './SettingsTab'; export * from './SettingsTab';
export * from './SettingsMobileTabs'; export * from './SettingsMobileTabs';
export * from './SettingsSearch'; export * from './SettingsSearch';
export * from './SettingsTreeSidebar';
export * from './QuickToggles';
export * from './LocaleTabs'; export * from './LocaleTabs';
export * from './LocalizedInput'; export * from './LocalizedInput';

View File

@@ -1836,8 +1836,11 @@
"title": "System Settings", "title": "System Settings",
"allCategories": "All categories", "allCategories": "All categories",
"noSettings": "No settings found", "noSettings": "No settings found",
"quickToggles": "Quick Toggles",
"modified": "Modified", "modified": "Modified",
"readOnly": "Read only", "readOnly": "Read only",
"badgeDb": "DB",
"badgeEnv": "ENV",
"reset": "Reset", "reset": "Reset",
"categoriesCount": "categories", "categoriesCount": "categories",
"settingsCount": "settings", "settingsCount": "settings",
@@ -1910,6 +1913,84 @@
"quickPresets": "Quick presets", "quickPresets": "Quick presets",
"resetAllColors": "Reset all colors", "resetAllColors": "Reset all colors",
"statusColors": "Status 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": { "categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)" "TELEGRAM_OIDC": "Telegram Login (OIDC)"

View File

@@ -1497,6 +1497,7 @@
"title": "تنظیمات سیستم", "title": "تنظیمات سیستم",
"allCategories": "همه دسته‌ها", "allCategories": "همه دسته‌ها",
"noSettings": "تنظیماتی یافت نشد", "noSettings": "تنظیماتی یافت نشد",
"quickToggles": "تغییر سریع",
"modified": "تغییر یافته", "modified": "تغییر یافته",
"readOnly": "فقط خواندنی", "readOnly": "فقط خواندنی",
"reset": "بازنشانی", "reset": "بازنشانی",
@@ -1571,6 +1572,86 @@
"quickPresets": "پیش‌تنظیم‌های سریع", "quickPresets": "پیش‌تنظیم‌های سریع",
"resetAllColors": "بازنشانی همه رنگ‌ها", "resetAllColors": "بازنشانی همه رنگ‌ها",
"statusColors": "رنگ‌های وضعیت", "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": { "categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)" "TELEGRAM_OIDC": "Telegram Login (OIDC)"

View File

@@ -1861,8 +1861,11 @@
"title": "Настройки системы", "title": "Настройки системы",
"allCategories": "Все категории", "allCategories": "Все категории",
"noSettings": "Настройки не найдены", "noSettings": "Настройки не найдены",
"quickToggles": "Быстрые переключатели",
"modified": "Изменено", "modified": "Изменено",
"readOnly": "Только чтение", "readOnly": "Только чтение",
"badgeDb": "БД",
"badgeEnv": "ENV",
"reset": "Сбросить", "reset": "Сбросить",
"categoriesCount": "категорий", "categoriesCount": "категорий",
"settingsCount": "настроек", "settingsCount": "настроек",
@@ -1936,6 +1939,74 @@
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl", "googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
"googleLabelHint": "Метка конверсии для отслеживания событий", "googleLabelHint": "Метка конверсии для отслеживания событий",
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.", "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": { "presets": {
"standard": "Стандарт", "standard": "Стандарт",
"ocean": "Океан", "ocean": "Океан",

View File

@@ -1535,6 +1535,7 @@
"title": "系统设置", "title": "系统设置",
"allCategories": "所有分类", "allCategories": "所有分类",
"noSettings": "未找到设置", "noSettings": "未找到设置",
"quickToggles": "快速开关",
"modified": "已修改", "modified": "已修改",
"readOnly": "只读", "readOnly": "只读",
"reset": "重置", "reset": "重置",
@@ -1609,6 +1610,86 @@
"quickPresets": "快速预设", "quickPresets": "快速预设",
"resetAllColors": "重置所有颜色", "resetAllColors": "重置所有颜色",
"statusColors": "状态颜色", "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": { "categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget", "TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)" "TELEGRAM_OIDC": "Telegram Login (OIDC)"

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings'; import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors'; import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings'; 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 { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab'; import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab'; import { BrandingTab } from '../components/admin/BrandingTab';
@@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab';
import { ThemeTab } from '../components/admin/ThemeTab'; import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab'; import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab'; import { SettingsTab } from '../components/admin/SettingsTab';
import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar';
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs'; import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
import { import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch';
SettingsSearch,
SettingsSearchMobile,
SettingsSearchResults,
} from '../components/admin/SettingsSearch';
// BackIcon // BackIcon
const BackIcon = () => ( const BackIcon = () => (
@@ -33,17 +30,18 @@ const BackIcon = () => (
</svg> </svg>
); );
// Find section ID by category key // ChevronRight for breadcrumbs
function findSectionByCategory(categoryKey: string): string | null { const ChevronRightIcon = () => (
for (const section of MENU_SECTIONS) { <svg
for (const item of section.items) { className="h-3.5 w-3.5 text-dark-600"
if (item.categories?.includes(categoryKey)) { fill="none"
return item.id; viewBox="0 0 24 24"
} stroke="currentColor"
} strokeWidth={2}
} >
return null; <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
} </svg>
);
export default function AdminSettings() { export default function AdminSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -73,23 +71,29 @@ export default function AdminSettings() {
queryFn: () => adminSettingsApi.getSettings(), queryFn: () => adminSettingsApi.getSettings(),
}); });
// Get current menu item configuration // Find active tree info (group + child for tree sub-items)
const currentMenuItem = useMemo(() => { // No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial
for (const section of MENU_SECTIONS) { let activeTreeInfo: {
const item = section.items.find((i: MenuItem) => i.id === activeSection); group: (typeof SETTINGS_TREE.groups)[number];
if (item) return item; 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(() => { 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<string, SettingDefinition[]>(); const categoryMap = new Map<string, SettingDefinition[]>();
for (const setting of allSettings) { for (const setting of allSettings) {
if (currentMenuItem.categories.includes(setting.category.key)) { if (categoryKeys.includes(setting.category.key)) {
if (!categoryMap.has(setting.category.key)) { if (!categoryMap.has(setting.category.key)) {
categoryMap.set(setting.category.key, []); categoryMap.set(setting.category.key, []);
} }
@@ -102,7 +106,7 @@ export default function AdminSettings() {
label: t(`admin.settings.categories.${key}`, key), label: t(`admin.settings.categories.${key}`, key),
settings, settings,
})); }));
}, [currentMenuItem, allSettings, t]); }, [activeTreeInfo, allSettings, t]);
// Filter settings for search - GLOBAL search across all settings // Filter settings for search - GLOBAL search across all settings
const filteredSettings = useMemo(() => { const filteredSettings = useMemo(() => {
@@ -112,24 +116,14 @@ export default function AdminSettings() {
if (!q) return []; if (!q) return [];
return allSettings.filter((s: SettingDefinition) => { return allSettings.filter((s: SettingDefinition) => {
// Search by key
if (s.key.toLowerCase().includes(q)) return true; if (s.key.toLowerCase().includes(q)) return true;
// Search by original name
if (s.name?.toLowerCase().includes(q)) return true; if (s.name?.toLowerCase().includes(q)) return true;
// Search by translated name
const formattedKey = formatSettingKey(s.name || s.key); const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true; if (translatedName.toLowerCase().includes(q)) return true;
// Search by description
if (s.hint?.description?.toLowerCase().includes(q)) return true; if (s.hint?.description?.toLowerCase().includes(q)) return true;
// Search by category
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key); const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true; if (categoryLabel.toLowerCase().includes(q)) return true;
return false; return false;
}); });
}, [allSettings, searchQuery, t]); }, [allSettings, searchQuery, t]);
@@ -140,19 +134,42 @@ export default function AdminSettings() {
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key)); return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
}, [allSettings, favorites]); }, [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 // Handle setting selection from autocomplete
const handleSelectSetting = useCallback( const handleSelectSetting = useCallback(
(setting: SettingDefinition) => { (setting: SettingDefinition) => {
const sectionId = findSectionByCategory(setting.category.key); const location = findTreeLocation(setting.category.key);
if (sectionId) { if (location) {
setActiveSection(sectionId); setActiveSection(location.subItemId);
} }
// Set search to setting key to filter to just this setting
setSearchQuery(setting.key); setSearchQuery(setting.key);
}, },
[setActiveSection, setSearchQuery], [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 // Render content based on active section
const renderContent = () => { const renderContent = () => {
// If searching, always show search results regardless of active section // If searching, always show search results regardless of active section
@@ -186,17 +203,7 @@ export default function AdminSettings() {
/> />
); );
default: default:
if ( if (activeTreeInfo) {
[
'payments',
'subscriptions',
'interface',
'notifications',
'database',
'system',
'users',
].includes(activeSection)
) {
return ( return (
<SettingsTab <SettingsTab
categories={currentCategories} categories={currentCategories}
@@ -233,7 +240,7 @@ export default function AdminSettings() {
{/* Desktop Layout - fixed sidebar, scrollable content */} {/* Desktop Layout - fixed sidebar, scrollable content */}
<div className="hidden h-[calc(100vh-120px)] lg:flex"> <div className="hidden h-[calc(100vh-120px)] lg:flex">
{/* Fixed Sidebar */} {/* Fixed Sidebar */}
<div className="w-64 shrink-0 overflow-y-auto border-r border-dark-700/50"> <div className="w-[264px] shrink-0 overflow-y-auto border-r border-dark-700/50">
<div className="border-b border-dark-700/50 p-4"> <div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */} {/* Show back button only on web, not in Telegram Mini App */}
@@ -241,6 +248,7 @@ export default function AdminSettings() {
<button <button
onClick={() => navigate('/admin')} onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
aria-label={t('admin.settings.backToAdmin')}
> >
<BackIcon /> <BackIcon />
</button> </button>
@@ -248,67 +256,52 @@ export default function AdminSettings() {
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1> <h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
</div> </div>
</div> </div>
<nav className="space-y-1 p-2"> <SettingsTreeSidebar
{MENU_SECTIONS.map((section, sectionIdx) => ( activeSection={activeSection}
<div key={section.id}> onSectionChange={setActiveSection}
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />} favoritesCount={favorites.length}
{section.items.map((item) => { searchQuery={searchQuery}
const isActive = activeSection === item.id; onSearchChange={setSearchQuery}
const hasIcon = item.iconType === 'star'; allSettings={allSettings}
return ( onSelectSetting={handleSelectSetting}
<button />
key={item.id}
onClick={() => setActiveSection(item.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`}
>
{hasIcon && (
<svg
className={`h-4 w-4 ${isActive ? 'fill-current' : ''}`}
fill={isActive ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
)}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favorites.length > 0 && (
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favorites.length}
</span>
)}
</button>
);
})}
</div>
))}
</nav>
</div> </div>
{/* Scrollable Content */} {/* Scrollable Content */}
<div className="min-w-0 flex-1 overflow-y-auto p-6"> <div className="min-w-0 flex-1 overflow-y-auto p-6">
{/* Breadcrumb for tree sub-items */}
{activeTreeInfo && !searchQuery.trim() && (
<div className="mb-2 flex items-center gap-1.5 text-xs">
<button
onClick={() => setActiveSection(activeTreeInfo.group.children[0].id)}
className="text-dark-500 transition-colors hover:text-dark-300"
>
{t(`admin.settings.groups.${activeTreeInfo.group.id}`)}
</button>
<ChevronRightIcon />
<span className="text-dark-300">
{t(`admin.settings.tree.${activeTreeInfo.child.id}`)}
</span>
</div>
)}
{/* Title + count badges */}
<div className="mb-4 flex items-center gap-3"> <div className="mb-4 flex items-center gap-3">
<h2 className="truncate text-xl font-semibold text-dark-100"> <h2 className="truncate text-xl font-semibold text-dark-100">{sectionTitle}</h2>
{t(`admin.settings.${activeSection}`)} {totalCount > 0 && !searchQuery.trim() && activeTreeInfo && (
</h2> <div className="flex items-center gap-2">
<div className="flex-1" /> <span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
<SettingsSearch {t('admin.settings.totalCount', { count: totalCount })}
searchQuery={searchQuery} </span>
setSearchQuery={setSearchQuery} {modifiedCount > 0 && (
resultsCount={filteredSettings.length} <span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
allSettings={allSettings} {t('admin.settings.modifiedCount', { count: modifiedCount })}
onSelectSetting={handleSelectSetting} </span>
/> )}
</div>
)}
</div> </div>
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} /> <SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
{renderContent()} {renderContent()}
</div> </div>