mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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>
|
||||
|
||||
70
src/components/admin/QuickToggles.tsx
Normal file
70
src/components/admin/QuickToggles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,43 +27,75 @@ 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>
|
||||
{/* 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' }}
|
||||
>
|
||||
{allItems.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
|
||||
return (
|
||||
{/* Favorites chip */}
|
||||
<button
|
||||
key={item.id}
|
||||
ref={isActive ? activeRef : null}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
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 ${
|
||||
isActive
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||
<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 ${
|
||||
isActive
|
||||
isFavoritesActive
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-warning-500/20 text-warning-400'
|
||||
}`}
|
||||
@@ -71,8 +104,78 @@ export function SettingsMobileTabs({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,18 +43,19 @@ export function SettingsTab({
|
||||
},
|
||||
});
|
||||
|
||||
// If searching, show flat list
|
||||
// Search mode: flat list of filtered results
|
||||
if (searchQuery) {
|
||||
if (filteredSettings.length === 0) {
|
||||
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">
|
||||
<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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredSettings.map((setting) => (
|
||||
<SettingRow
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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)}
|
||||
@@ -78,72 +64,59 @@ export function SettingsTab({
|
||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||
isUpdating={updateSettingMutation.isPending}
|
||||
isResetting={resetSettingMutation.isPending}
|
||||
isLast={idx === filteredSettings.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
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 })
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
162
src/components/admin/SettingsTableRow.tsx
Normal file
162
src/components/admin/SettingsTableRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
322
src/components/admin/SettingsTreeSidebar.tsx
Normal file
322
src/components/admin/SettingsTreeSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: [
|
||||
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
|
||||
export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||
specialItems: [
|
||||
{ id: 'favorites', iconType: 'star' },
|
||||
{ id: 'branding', iconType: null },
|
||||
{ id: 'theme', iconType: null },
|
||||
{ id: 'analytics', iconType: null },
|
||||
{ id: 'buttons', iconType: null },
|
||||
{ id: 'branding', icon: '🎨' },
|
||||
{ id: 'theme', icon: '🌈' },
|
||||
{ id: 'analytics', icon: '📊' },
|
||||
{ id: 'buttons', icon: '📱' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
items: [
|
||||
groups: [
|
||||
{
|
||||
id: 'payments',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'YOOKASSA',
|
||||
'CRYPTOBOT',
|
||||
'HELEKET',
|
||||
'PLATEGA',
|
||||
'TRIBUTE',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
'TELEGRAM',
|
||||
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',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'SUBSCRIPTIONS_CORE',
|
||||
'SIMPLE_SUBSCRIPTION',
|
||||
'PERIODS',
|
||||
'SUBSCRIPTION_PRICES',
|
||||
'TRAFFIC',
|
||||
'TRAFFIC_PACKAGES',
|
||||
'TRIAL',
|
||||
'AUTOPAY',
|
||||
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',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'INTERFACE',
|
||||
'INTERFACE_BRANDING',
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'TELEGRAM_WIDGET',
|
||||
'TELEGRAM_OIDC',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
],
|
||||
},
|
||||
icon: '🖥️',
|
||||
children: [
|
||||
{
|
||||
id: 'notifications',
|
||||
iconType: null,
|
||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
||||
id: 'iface_general',
|
||||
categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'],
|
||||
},
|
||||
{ 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: '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',
|
||||
iconType: null,
|
||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1836,8 +1836,11 @@
|
||||
"title": "System Settings",
|
||||
"allCategories": "All categories",
|
||||
"noSettings": "No settings found",
|
||||
"quickToggles": "Quick Toggles",
|
||||
"modified": "Modified",
|
||||
"readOnly": "Read only",
|
||||
"badgeDb": "DB",
|
||||
"badgeEnv": "ENV",
|
||||
"reset": "Reset",
|
||||
"categoriesCount": "categories",
|
||||
"settingsCount": "settings",
|
||||
@@ -1910,6 +1913,84 @@
|
||||
"quickPresets": "Quick presets",
|
||||
"resetAllColors": "Reset all colors",
|
||||
"statusColors": "Status colors",
|
||||
"favorites": "Favorites",
|
||||
"branding": "Branding",
|
||||
"theme": "Theme",
|
||||
"payments": "Payments",
|
||||
"subscriptions": "Subscriptions",
|
||||
"interface": "Interface",
|
||||
"notifications": "Notifications",
|
||||
"database": "Database",
|
||||
"system": "System",
|
||||
"users": "Users",
|
||||
"customization": "Customization",
|
||||
"settingsLabel": "Settings",
|
||||
"backToAdmin": "Back to admin",
|
||||
"totalCount": "{{count}} total",
|
||||
"modifiedCount": "{{count}} modified",
|
||||
"groups": {
|
||||
"payments": "Payments",
|
||||
"subscriptions": "Subscriptions",
|
||||
"interface": "Interface",
|
||||
"users": "Users",
|
||||
"notifications": "Notifications",
|
||||
"database": "Database",
|
||||
"system": "System"
|
||||
},
|
||||
"tree": {
|
||||
"payments_general": "General",
|
||||
"payments_stars": "Telegram Stars",
|
||||
"payments_yookassa": "YooKassa",
|
||||
"payments_cryptobot": "CryptoBot",
|
||||
"payments_cloudpayments": "CloudPayments",
|
||||
"payments_freekassa": "FreeKassa",
|
||||
"payments_kassa_ai": "Kassa AI",
|
||||
"payments_platega": "Platega",
|
||||
"payments_pal24": "PAL24",
|
||||
"payments_heleket": "Heleket",
|
||||
"payments_mulenpay": "MulenPay",
|
||||
"payments_tribute": "Tribute",
|
||||
"payments_wata": "Wata",
|
||||
"payments_riopay": "RioPay",
|
||||
"payments_severpay": "SeverPay",
|
||||
"subs_core": "Core",
|
||||
"subs_trial": "Trial",
|
||||
"subs_pricing": "Pricing",
|
||||
"subs_periods": "Periods",
|
||||
"subs_traffic": "Traffic",
|
||||
"subs_simple": "Simple subscription",
|
||||
"subs_autopay": "Auto-pay",
|
||||
"iface_general": "General",
|
||||
"iface_connect": "Connect button",
|
||||
"iface_miniapp": "MiniApp",
|
||||
"iface_happ": "HAPP",
|
||||
"iface_widget": "Login Widget",
|
||||
"iface_oidc": "OIDC",
|
||||
"iface_skip": "Skip steps",
|
||||
"iface_additional": "Additional",
|
||||
"users_support": "Support",
|
||||
"users_referral": "Referral",
|
||||
"users_channel": "Channel",
|
||||
"users_localization": "Localization",
|
||||
"users_moderation": "Moderation",
|
||||
"notif_user": "User notifications",
|
||||
"notif_admin": "Admin notifications",
|
||||
"notif_reports": "Reports",
|
||||
"db_general": "General",
|
||||
"db_postgres": "PostgreSQL",
|
||||
"db_sqlite": "SQLite",
|
||||
"db_redis": "Redis",
|
||||
"sys_core": "Core & Debug",
|
||||
"sys_remnawave": "RemnaWave",
|
||||
"sys_webapi": "Web API",
|
||||
"sys_webhook": "Webhook",
|
||||
"sys_server": "Server status",
|
||||
"sys_monitoring": "Monitoring",
|
||||
"sys_maintenance": "Maintenance",
|
||||
"sys_backup": "Backup",
|
||||
"sys_version": "Version",
|
||||
"sys_logging": "Logging"
|
||||
},
|
||||
"categories": {
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
|
||||
@@ -1497,6 +1497,7 @@
|
||||
"title": "تنظیمات سیستم",
|
||||
"allCategories": "همه دستهها",
|
||||
"noSettings": "تنظیماتی یافت نشد",
|
||||
"quickToggles": "تغییر سریع",
|
||||
"modified": "تغییر یافته",
|
||||
"readOnly": "فقط خواندنی",
|
||||
"reset": "بازنشانی",
|
||||
@@ -1571,6 +1572,86 @@
|
||||
"quickPresets": "پیشتنظیمهای سریع",
|
||||
"resetAllColors": "بازنشانی همه رنگها",
|
||||
"statusColors": "رنگهای وضعیت",
|
||||
"favorites": "علاقهمندیها",
|
||||
"branding": "برندسازی",
|
||||
"theme": "پوسته",
|
||||
"payments": "پرداختها",
|
||||
"subscriptions": "اشتراکها",
|
||||
"interface": "رابط کاربری",
|
||||
"notifications": "اعلانها",
|
||||
"database": "پایگاه داده",
|
||||
"system": "سیستم",
|
||||
"users": "کاربران",
|
||||
"customization": "سفارشیسازی",
|
||||
"settingsLabel": "تنظیمات",
|
||||
"backToAdmin": "بازگشت به مدیریت",
|
||||
"totalCount": "{{count}} مورد",
|
||||
"modifiedCount": "{{count}} تغییر یافته",
|
||||
"badgeDb": "پایگاه داده",
|
||||
"badgeEnv": "متغیر محیطی",
|
||||
"groups": {
|
||||
"payments": "پرداختها",
|
||||
"subscriptions": "اشتراکها",
|
||||
"interface": "رابط کاربری",
|
||||
"users": "کاربران",
|
||||
"notifications": "اعلانها",
|
||||
"database": "پایگاه داده",
|
||||
"system": "سیستم"
|
||||
},
|
||||
"tree": {
|
||||
"payments_general": "عمومی",
|
||||
"payments_stars": "Telegram Stars",
|
||||
"payments_yookassa": "YooKassa",
|
||||
"payments_cryptobot": "CryptoBot",
|
||||
"payments_cloudpayments": "CloudPayments",
|
||||
"payments_freekassa": "FreeKassa",
|
||||
"payments_kassa_ai": "Kassa AI",
|
||||
"payments_platega": "Platega",
|
||||
"payments_pal24": "PAL24",
|
||||
"payments_heleket": "Heleket",
|
||||
"payments_mulenpay": "MulenPay",
|
||||
"payments_tribute": "Tribute",
|
||||
"payments_wata": "Wata",
|
||||
"payments_riopay": "RioPay",
|
||||
"payments_severpay": "SeverPay",
|
||||
"subs_core": "هسته",
|
||||
"subs_trial": "آزمایشی",
|
||||
"subs_pricing": "قیمتگذاری",
|
||||
"subs_periods": "دورهها",
|
||||
"subs_traffic": "ترافیک",
|
||||
"subs_simple": "اشتراک ساده",
|
||||
"subs_autopay": "پرداخت خودکار",
|
||||
"iface_general": "عمومی",
|
||||
"iface_connect": "دکمه اتصال",
|
||||
"iface_miniapp": "MiniApp",
|
||||
"iface_happ": "HAPP",
|
||||
"iface_widget": "ویجت ورود",
|
||||
"iface_oidc": "OIDC",
|
||||
"iface_skip": "رد کردن مراحل",
|
||||
"iface_additional": "اضافی",
|
||||
"users_support": "پشتیبانی",
|
||||
"users_referral": "معرفی",
|
||||
"users_channel": "کانال",
|
||||
"users_localization": "بومیسازی",
|
||||
"users_moderation": "مدیریت محتوا",
|
||||
"notif_user": "اعلانهای کاربر",
|
||||
"notif_admin": "اعلانهای مدیر",
|
||||
"notif_reports": "گزارشها",
|
||||
"db_general": "عمومی",
|
||||
"db_postgres": "PostgreSQL",
|
||||
"db_sqlite": "SQLite",
|
||||
"db_redis": "Redis",
|
||||
"sys_core": "هسته و اشکالزدایی",
|
||||
"sys_remnawave": "RemnaWave",
|
||||
"sys_webapi": "Web API",
|
||||
"sys_webhook": "Webhook",
|
||||
"sys_server": "وضعیت سرور",
|
||||
"sys_monitoring": "نظارت",
|
||||
"sys_maintenance": "نگهداری",
|
||||
"sys_backup": "پشتیبانگیری",
|
||||
"sys_version": "نسخه",
|
||||
"sys_logging": "لاگها"
|
||||
},
|
||||
"categories": {
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
|
||||
@@ -1861,8 +1861,11 @@
|
||||
"title": "Настройки системы",
|
||||
"allCategories": "Все категории",
|
||||
"noSettings": "Настройки не найдены",
|
||||
"quickToggles": "Быстрые переключатели",
|
||||
"modified": "Изменено",
|
||||
"readOnly": "Только чтение",
|
||||
"badgeDb": "БД",
|
||||
"badgeEnv": "ENV",
|
||||
"reset": "Сбросить",
|
||||
"categoriesCount": "категорий",
|
||||
"settingsCount": "настроек",
|
||||
@@ -1936,6 +1939,74 @@
|
||||
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
|
||||
"googleLabelHint": "Метка конверсии для отслеживания событий",
|
||||
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
|
||||
"customization": "Кастомизация",
|
||||
"settingsLabel": "Настройки",
|
||||
"backToAdmin": "Назад к админке",
|
||||
"totalCount": "{{count}} всего",
|
||||
"modifiedCount": "{{count}} изменено",
|
||||
"groups": {
|
||||
"payments": "Платежи",
|
||||
"subscriptions": "Подписки",
|
||||
"interface": "Интерфейс",
|
||||
"users": "Пользователи",
|
||||
"notifications": "Уведомления",
|
||||
"database": "База данных",
|
||||
"system": "Система"
|
||||
},
|
||||
"tree": {
|
||||
"payments_general": "Основные",
|
||||
"payments_stars": "Telegram Stars",
|
||||
"payments_yookassa": "YooKassa",
|
||||
"payments_cryptobot": "CryptoBot",
|
||||
"payments_cloudpayments": "CloudPayments",
|
||||
"payments_freekassa": "FreeKassa",
|
||||
"payments_kassa_ai": "Kassa AI",
|
||||
"payments_platega": "Platega",
|
||||
"payments_pal24": "PAL24",
|
||||
"payments_heleket": "Heleket",
|
||||
"payments_mulenpay": "MulenPay",
|
||||
"payments_tribute": "Tribute",
|
||||
"payments_wata": "Wata",
|
||||
"payments_riopay": "RioPay",
|
||||
"payments_severpay": "SeverPay",
|
||||
"subs_core": "Основные",
|
||||
"subs_trial": "Пробный период",
|
||||
"subs_pricing": "Цены",
|
||||
"subs_periods": "Периоды",
|
||||
"subs_traffic": "Трафик",
|
||||
"subs_simple": "Простая подписка",
|
||||
"subs_autopay": "Автоплатёж",
|
||||
"iface_general": "Основные",
|
||||
"iface_connect": "Кнопка подключения",
|
||||
"iface_miniapp": "MiniApp",
|
||||
"iface_happ": "HAPP",
|
||||
"iface_widget": "Виджет входа",
|
||||
"iface_oidc": "OIDC",
|
||||
"iface_skip": "Пропуск шагов",
|
||||
"iface_additional": "Дополнительно",
|
||||
"users_support": "Поддержка",
|
||||
"users_referral": "Реферальная система",
|
||||
"users_channel": "Канал",
|
||||
"users_localization": "Локализация",
|
||||
"users_moderation": "Модерация",
|
||||
"notif_user": "Уведомления пользователей",
|
||||
"notif_admin": "Уведомления админов",
|
||||
"notif_reports": "Отчёты",
|
||||
"db_general": "Основные",
|
||||
"db_postgres": "PostgreSQL",
|
||||
"db_sqlite": "SQLite",
|
||||
"db_redis": "Redis",
|
||||
"sys_core": "Ядро и отладка",
|
||||
"sys_remnawave": "RemnaWave",
|
||||
"sys_webapi": "Web API",
|
||||
"sys_webhook": "Webhook",
|
||||
"sys_server": "Статус сервера",
|
||||
"sys_monitoring": "Мониторинг",
|
||||
"sys_maintenance": "Обслуживание",
|
||||
"sys_backup": "Бэкап",
|
||||
"sys_version": "Версия",
|
||||
"sys_logging": "Логи"
|
||||
},
|
||||
"presets": {
|
||||
"standard": "Стандарт",
|
||||
"ocean": "Океан",
|
||||
|
||||
@@ -1535,6 +1535,7 @@
|
||||
"title": "系统设置",
|
||||
"allCategories": "所有分类",
|
||||
"noSettings": "未找到设置",
|
||||
"quickToggles": "快速开关",
|
||||
"modified": "已修改",
|
||||
"readOnly": "只读",
|
||||
"reset": "重置",
|
||||
@@ -1609,6 +1610,86 @@
|
||||
"quickPresets": "快速预设",
|
||||
"resetAllColors": "重置所有颜色",
|
||||
"statusColors": "状态颜色",
|
||||
"favorites": "收藏",
|
||||
"branding": "品牌",
|
||||
"theme": "主题",
|
||||
"payments": "支付",
|
||||
"subscriptions": "订阅",
|
||||
"interface": "界面",
|
||||
"notifications": "通知",
|
||||
"database": "数据库",
|
||||
"system": "系统",
|
||||
"users": "用户",
|
||||
"customization": "自定义",
|
||||
"settingsLabel": "设置",
|
||||
"backToAdmin": "返回管理面板",
|
||||
"totalCount": "共 {{count}} 项",
|
||||
"modifiedCount": "已修改 {{count}} 项",
|
||||
"badgeDb": "数据库",
|
||||
"badgeEnv": "环境变量",
|
||||
"groups": {
|
||||
"payments": "支付",
|
||||
"subscriptions": "订阅",
|
||||
"interface": "界面",
|
||||
"users": "用户",
|
||||
"notifications": "通知",
|
||||
"database": "数据库",
|
||||
"system": "系统"
|
||||
},
|
||||
"tree": {
|
||||
"payments_general": "通用",
|
||||
"payments_stars": "Telegram Stars",
|
||||
"payments_yookassa": "YooKassa",
|
||||
"payments_cryptobot": "CryptoBot",
|
||||
"payments_cloudpayments": "CloudPayments",
|
||||
"payments_freekassa": "FreeKassa",
|
||||
"payments_kassa_ai": "Kassa AI",
|
||||
"payments_platega": "Platega",
|
||||
"payments_pal24": "PAL24",
|
||||
"payments_heleket": "Heleket",
|
||||
"payments_mulenpay": "MulenPay",
|
||||
"payments_tribute": "Tribute",
|
||||
"payments_wata": "Wata",
|
||||
"payments_riopay": "RioPay",
|
||||
"payments_severpay": "SeverPay",
|
||||
"subs_core": "核心",
|
||||
"subs_trial": "试用",
|
||||
"subs_pricing": "定价",
|
||||
"subs_periods": "周期",
|
||||
"subs_traffic": "流量",
|
||||
"subs_simple": "简单订阅",
|
||||
"subs_autopay": "自动支付",
|
||||
"iface_general": "通用",
|
||||
"iface_connect": "连接按钮",
|
||||
"iface_miniapp": "MiniApp",
|
||||
"iface_happ": "HAPP",
|
||||
"iface_widget": "登录小部件",
|
||||
"iface_oidc": "OIDC",
|
||||
"iface_skip": "跳过步骤",
|
||||
"iface_additional": "附加",
|
||||
"users_support": "支持",
|
||||
"users_referral": "推荐",
|
||||
"users_channel": "频道",
|
||||
"users_localization": "本地化",
|
||||
"users_moderation": "审核",
|
||||
"notif_user": "用户通知",
|
||||
"notif_admin": "管理员通知",
|
||||
"notif_reports": "报告",
|
||||
"db_general": "通用",
|
||||
"db_postgres": "PostgreSQL",
|
||||
"db_sqlite": "SQLite",
|
||||
"db_redis": "Redis",
|
||||
"sys_core": "核心与调试",
|
||||
"sys_remnawave": "RemnaWave",
|
||||
"sys_webapi": "Web API",
|
||||
"sys_webhook": "Webhook",
|
||||
"sys_server": "服务器状态",
|
||||
"sys_monitoring": "监控",
|
||||
"sys_maintenance": "维护",
|
||||
"sys_backup": "备份",
|
||||
"sys_version": "版本",
|
||||
"sys_logging": "日志"
|
||||
},
|
||||
"categories": {
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||
import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||
import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
@@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab';
|
||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||
import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar';
|
||||
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
||||
import {
|
||||
SettingsSearch,
|
||||
SettingsSearchMobile,
|
||||
SettingsSearchResults,
|
||||
} from '../components/admin/SettingsSearch';
|
||||
import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch';
|
||||
|
||||
// BackIcon
|
||||
const BackIcon = () => (
|
||||
@@ -33,17 +30,18 @@ const BackIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Find section ID by category key
|
||||
function findSectionByCategory(categoryKey: string): string | null {
|
||||
for (const section of MENU_SECTIONS) {
|
||||
for (const item of section.items) {
|
||||
if (item.categories?.includes(categoryKey)) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ChevronRight for breadcrumbs
|
||||
const ChevronRightIcon = () => (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-dark-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdminSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -73,23 +71,29 @@ export default function AdminSettings() {
|
||||
queryFn: () => adminSettingsApi.getSettings(),
|
||||
});
|
||||
|
||||
// Get current menu item configuration
|
||||
const currentMenuItem = useMemo(() => {
|
||||
for (const section of MENU_SECTIONS) {
|
||||
const item = section.items.find((i: MenuItem) => i.id === activeSection);
|
||||
if (item) return item;
|
||||
// Find active tree info (group + child for tree sub-items)
|
||||
// No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial
|
||||
let activeTreeInfo: {
|
||||
group: (typeof SETTINGS_TREE.groups)[number];
|
||||
child: (typeof SETTINGS_TREE.groups)[number]['children'][number];
|
||||
} | null = null;
|
||||
for (const group of SETTINGS_TREE.groups) {
|
||||
const child = group.children.find((c) => c.id === activeSection);
|
||||
if (child) {
|
||||
activeTreeInfo = { group, child };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [activeSection]);
|
||||
|
||||
// Get categories for current section
|
||||
// Get categories for current sub-item
|
||||
const currentCategories = useMemo(() => {
|
||||
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
|
||||
if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return [];
|
||||
|
||||
const categoryKeys = activeTreeInfo.child.categories;
|
||||
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||
|
||||
for (const setting of allSettings) {
|
||||
if (currentMenuItem.categories.includes(setting.category.key)) {
|
||||
if (categoryKeys.includes(setting.category.key)) {
|
||||
if (!categoryMap.has(setting.category.key)) {
|
||||
categoryMap.set(setting.category.key, []);
|
||||
}
|
||||
@@ -102,7 +106,7 @@ export default function AdminSettings() {
|
||||
label: t(`admin.settings.categories.${key}`, key),
|
||||
settings,
|
||||
}));
|
||||
}, [currentMenuItem, allSettings, t]);
|
||||
}, [activeTreeInfo, allSettings, t]);
|
||||
|
||||
// Filter settings for search - GLOBAL search across all settings
|
||||
const filteredSettings = useMemo(() => {
|
||||
@@ -112,24 +116,14 @@ export default function AdminSettings() {
|
||||
if (!q) return [];
|
||||
|
||||
return allSettings.filter((s: SettingDefinition) => {
|
||||
// Search by key
|
||||
if (s.key.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by original name
|
||||
if (s.name?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by translated name
|
||||
const formattedKey = formatSettingKey(s.name || s.key);
|
||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
if (translatedName.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by description
|
||||
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by category
|
||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [allSettings, searchQuery, t]);
|
||||
@@ -140,19 +134,42 @@ export default function AdminSettings() {
|
||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||
}, [allSettings, favorites]);
|
||||
|
||||
// Count of modified settings
|
||||
const modifiedCount = useMemo(() => {
|
||||
if (!allSettings || !Array.isArray(allSettings)) return 0;
|
||||
return allSettings.filter((s: SettingDefinition) => s.has_override).length;
|
||||
}, [allSettings]);
|
||||
|
||||
// Total settings count
|
||||
const totalCount = useMemo(() => {
|
||||
if (!allSettings || !Array.isArray(allSettings)) return 0;
|
||||
return allSettings.length;
|
||||
}, [allSettings]);
|
||||
|
||||
// Handle setting selection from autocomplete
|
||||
const handleSelectSetting = useCallback(
|
||||
(setting: SettingDefinition) => {
|
||||
const sectionId = findSectionByCategory(setting.category.key);
|
||||
if (sectionId) {
|
||||
setActiveSection(sectionId);
|
||||
const location = findTreeLocation(setting.category.key);
|
||||
if (location) {
|
||||
setActiveSection(location.subItemId);
|
||||
}
|
||||
// Set search to setting key to filter to just this setting
|
||||
setSearchQuery(setting.key);
|
||||
},
|
||||
[setActiveSection, setSearchQuery],
|
||||
);
|
||||
|
||||
// Get the display title for the current section
|
||||
const sectionTitle = useMemo(() => {
|
||||
// Special items
|
||||
const specialItem = SETTINGS_TREE.specialItems.find((item) => item.id === activeSection);
|
||||
if (specialItem) return t(`admin.settings.${specialItem.id}`);
|
||||
|
||||
// Tree sub-items
|
||||
if (activeTreeInfo) return t(`admin.settings.tree.${activeTreeInfo.child.id}`);
|
||||
|
||||
return t('admin.settings.title');
|
||||
}, [activeSection, activeTreeInfo, t]);
|
||||
|
||||
// Render content based on active section
|
||||
const renderContent = () => {
|
||||
// If searching, always show search results regardless of active section
|
||||
@@ -186,17 +203,7 @@ export default function AdminSettings() {
|
||||
/>
|
||||
);
|
||||
default:
|
||||
if (
|
||||
[
|
||||
'payments',
|
||||
'subscriptions',
|
||||
'interface',
|
||||
'notifications',
|
||||
'database',
|
||||
'system',
|
||||
'users',
|
||||
].includes(activeSection)
|
||||
) {
|
||||
if (activeTreeInfo) {
|
||||
return (
|
||||
<SettingsTab
|
||||
categories={currentCategories}
|
||||
@@ -233,7 +240,7 @@ export default function AdminSettings() {
|
||||
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
||||
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
||||
{/* 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="flex items-center gap-3">
|
||||
{/* Show back button only on web, not in Telegram Mini App */}
|
||||
@@ -241,6 +248,7 @@ export default function AdminSettings() {
|
||||
<button
|
||||
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"
|
||||
aria-label={t('admin.settings.backToAdmin')}
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
@@ -248,67 +256,52 @@ export default function AdminSettings() {
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="space-y-1 p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<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>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h2 className="truncate text-xl font-semibold text-dark-100">
|
||||
{t(`admin.settings.${activeSection}`)}
|
||||
</h2>
|
||||
<div className="flex-1" />
|
||||
<SettingsSearch
|
||||
<SettingsTreeSidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
favoritesCount={favorites.length}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
resultsCount={filteredSettings.length}
|
||||
onSearchChange={setSearchQuery}
|
||||
allSettings={allSettings}
|
||||
onSelectSetting={handleSelectSetting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<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">
|
||||
<h2 className="truncate text-xl font-semibold text-dark-100">{sectionTitle}</h2>
|
||||
{totalCount > 0 && !searchQuery.trim() && activeTreeInfo && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.settings.totalCount', { count: totalCount })}
|
||||
</span>
|
||||
{modifiedCount > 0 && (
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.settings.modifiedCount', { count: modifiedCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user