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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
{ 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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user