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 { 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 (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{settings.map((setting) => (
<SettingRow
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{settings.map((setting, idx) => (
<SettingsTableRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(setting.key)}
@@ -53,6 +53,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
isLast={idx === settings.length - 1}
/>
))}
</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 { 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<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement>(null);
const [expandedGroup, setExpandedGroup] = useState<string | null>(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 (
<div
ref={scrollRef}
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{allItems.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
<div>
{/* Level 1: Favorites + special items + group chips */}
<div
ref={scrollRef}
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{/* 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 (
<button
key={item.id}
ref={isActive ? activeRef : null}
onClick={() => setActiveSection(item.id)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs ${
isActive
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400'
}`}
>
{favoritesCount}
</span>
)}
</button>
);
})}
{/* Special item chips (branding, theme, analytics, buttons) */}
{specialItems.map((item) => {
const isActive = isSpecialActive(item.id);
return (
<button
key={item.id}
ref={isActive ? activeRef : null}
onClick={() => {
setActiveSection(item.id);
setExpandedGroup(null);
}}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{item.icon && <span className="text-sm">{item.icon}</span>}
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
</button>
);
})}
{/* Group chips */}
{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>
);
}

View File

@@ -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<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({
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 (
<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 (
<div className="space-y-4">
{filteredSettings.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 className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredSettings.map((setting) => (
<SettingRow
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}
/>
))}
</div>
)}
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{filteredSettings.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 === filteredSettings.length - 1}
/>
))}
</div>
);
}
// Show accordion for subcategories
return (
<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>
// Normal mode: QuickToggles + settings by category
const allCategorySettings = categories.flatMap((c) => c.settings);
{/* Accordion content */}
{isExpanded && (
<div className="border-t border-dark-700/30 p-4 pt-0">
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
{cat.settings.map((setting) => (
<SettingRow
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}
/>
))}
</div>
if (allCategorySettings.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 (
<div>
<QuickToggles
settings={allCategorySettings}
onUpdate={(key, value) => updateSettingMutation.mutate({ key, value })}
disabled={updateSettingMutation.isPending}
/>
{categories.map((category) => {
if (category.settings.length === 0) return null;
return (
<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 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>
);
})}
{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>
);
}

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';
// 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 {

View File

@@ -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';