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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { StarIcon } from './icons';
|
import { StarIcon } from './icons';
|
||||||
import { SettingRow } from './SettingRow';
|
import { SettingsTableRow } from './SettingsTableRow';
|
||||||
|
|
||||||
interface FavoritesTabProps {
|
interface FavoritesTabProps {
|
||||||
settings: SettingDefinition[];
|
settings: SettingDefinition[];
|
||||||
@@ -42,9 +42,9 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="overflow-hidden rounded-xl border border-dark-700/40">
|
||||||
{settings.map((setting) => (
|
{settings.map((setting, idx) => (
|
||||||
<SettingRow
|
<SettingsTableRow
|
||||||
key={setting.key}
|
key={setting.key}
|
||||||
setting={setting}
|
setting={setting}
|
||||||
isFavorite={isFavorite(setting.key)}
|
isFavorite={isFavorite(setting.key)}
|
||||||
@@ -53,6 +53,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
|||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
isUpdating={updateSettingMutation.isPending}
|
isUpdating={updateSettingMutation.isPending}
|
||||||
isResetting={resetSettingMutation.isPending}
|
isResetting={resetSettingMutation.isPending}
|
||||||
|
isLast={idx === settings.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { useTranslation } from 'react-i18next';
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { MENU_SECTIONS } from './constants';
|
import { SETTINGS_TREE } from './constants';
|
||||||
import { StarIcon } from './icons';
|
import { StarIcon } from './icons';
|
||||||
|
|
||||||
interface SettingsMobileTabsProps {
|
interface SettingsMobileTabsProps {
|
||||||
@@ -17,6 +17,7 @@ export function SettingsMobileTabs({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const activeRef = useRef<HTMLButtonElement>(null);
|
const activeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
// Scroll active tab into view
|
// Scroll active tab into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,53 +27,155 @@ export function SettingsMobileTabs({
|
|||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const activeRect = activeEl.getBoundingClientRect();
|
const activeRect = activeEl.getBoundingClientRect();
|
||||||
|
|
||||||
// Check if active element is not fully visible
|
|
||||||
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
|
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
|
||||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
// Flatten all items from all sections
|
// Auto-expand the group containing the active section
|
||||||
const allItems = MENU_SECTIONS.flatMap((section) => section.items);
|
useEffect(() => {
|
||||||
|
for (const group of SETTINGS_TREE.groups) {
|
||||||
|
if (group.children.some((child) => child.id === activeSection)) {
|
||||||
|
setExpandedGroup(group.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
const handleGroupTap = (groupId: string) => {
|
||||||
|
if (expandedGroup === groupId) {
|
||||||
|
setExpandedGroup(null);
|
||||||
|
} else {
|
||||||
|
setExpandedGroup(groupId);
|
||||||
|
// Auto-select first child when expanding
|
||||||
|
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
|
||||||
|
if (group && group.children.length > 0) {
|
||||||
|
setActiveSection(group.children[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupActive = (groupId: string) => {
|
||||||
|
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
|
||||||
|
return group?.children.some((child) => child.id === activeSection) ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFavoritesActive = activeSection === 'favorites';
|
||||||
|
|
||||||
|
// Check if active section is a special item
|
||||||
|
const isSpecialActive = (itemId: string) => activeSection === itemId;
|
||||||
|
|
||||||
|
// Special items excluding favorites
|
||||||
|
const specialItems = SETTINGS_TREE.specialItems.filter((item) => item.id !== 'favorites');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
ref={scrollRef}
|
{/* Level 1: Favorites + special items + group chips */}
|
||||||
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
|
<div
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
ref={scrollRef}
|
||||||
>
|
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
|
||||||
{allItems.map((item) => {
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
const isActive = activeSection === item.id;
|
>
|
||||||
const hasIcon = item.iconType === 'star';
|
{/* Favorites chip */}
|
||||||
|
<button
|
||||||
|
ref={isFavoritesActive ? activeRef : null}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSection('favorites');
|
||||||
|
setExpandedGroup(null);
|
||||||
|
}}
|
||||||
|
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
isFavoritesActive
|
||||||
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StarIcon filled={isFavoritesActive} />
|
||||||
|
<span className="whitespace-nowrap">{t('admin.settings.favorites')}</span>
|
||||||
|
{favoritesCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 py-0.5 text-xs ${
|
||||||
|
isFavoritesActive
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'bg-warning-500/20 text-warning-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{favoritesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
return (
|
{/* Special item chips (branding, theme, analytics, buttons) */}
|
||||||
<button
|
{specialItems.map((item) => {
|
||||||
key={item.id}
|
const isActive = isSpecialActive(item.id);
|
||||||
ref={isActive ? activeRef : null}
|
return (
|
||||||
onClick={() => setActiveSection(item.id)}
|
<button
|
||||||
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
key={item.id}
|
||||||
isActive
|
ref={isActive ? activeRef : null}
|
||||||
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
onClick={() => {
|
||||||
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
setActiveSection(item.id);
|
||||||
}`}
|
setExpandedGroup(null);
|
||||||
>
|
}}
|
||||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||||
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
|
isActive
|
||||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
<span
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
className={`rounded-full px-1.5 py-0.5 text-xs ${
|
}`}
|
||||||
isActive
|
>
|
||||||
? 'bg-accent-500/20 text-accent-400'
|
{item.icon && <span className="text-sm">{item.icon}</span>}
|
||||||
: 'bg-warning-500/20 text-warning-400'
|
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
|
||||||
}`}
|
</button>
|
||||||
>
|
);
|
||||||
{favoritesCount}
|
})}
|
||||||
</span>
|
|
||||||
)}
|
{/* Group chips */}
|
||||||
</button>
|
{SETTINGS_TREE.groups.map((group) => {
|
||||||
);
|
const hasActiveChild = isGroupActive(group.id);
|
||||||
})}
|
const isExpanded = expandedGroup === group.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
ref={hasActiveChild ? activeRef : null}
|
||||||
|
onClick={() => handleGroupTap(group.id)}
|
||||||
|
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
||||||
|
hasActiveChild || isExpanded
|
||||||
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{group.icon}</span>
|
||||||
|
<span className="whitespace-nowrap">{t(`admin.settings.groups.${group.id}`)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level 2: Sub-item chips (shown when a group is expanded) */}
|
||||||
|
{expandedGroup && (
|
||||||
|
<div
|
||||||
|
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 pb-2"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{SETTINGS_TREE.groups
|
||||||
|
.find((g) => g.id === expandedGroup)
|
||||||
|
?.children.map((child) => {
|
||||||
|
const isActive = activeSection === child.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
onClick={() => setActiveSection(child.id)}
|
||||||
|
className={`shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-500/10 text-accent-400 ring-1 ring-accent-500/20'
|
||||||
|
: 'bg-dark-800/30 text-dark-500 active:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap">{t(`admin.settings.tree.${child.id}`)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { ChevronDownIcon } from './icons';
|
import { QuickToggles } from './QuickToggles';
|
||||||
import { SettingRow } from './SettingRow';
|
import { SettingsTableRow } from './SettingsTableRow';
|
||||||
|
|
||||||
interface CategoryGroup {
|
interface CategoryGroup {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -29,20 +28,6 @@ export function SettingsTab({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const toggleSection = (key: string) => {
|
|
||||||
setExpandedSections((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(key)) {
|
|
||||||
next.delete(key);
|
|
||||||
} else {
|
|
||||||
next.add(key);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSettingMutation = useMutation({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
adminSettingsApi.updateSetting(key, value),
|
adminSettingsApi.updateSetting(key, value),
|
||||||
@@ -58,92 +43,80 @@ export function SettingsTab({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If searching, show flat list
|
// Search mode: flat list of filtered results
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
|
if (filteredSettings.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
|
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="overflow-hidden rounded-xl border border-dark-700/40">
|
||||||
{filteredSettings.length === 0 ? (
|
{filteredSettings.map((setting, idx) => (
|
||||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
<SettingsTableRow
|
||||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
key={setting.key}
|
||||||
</div>
|
setting={setting}
|
||||||
) : (
|
isFavorite={isFavorite(setting.key)}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||||
{filteredSettings.map((setting) => (
|
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||||
<SettingRow
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
key={setting.key}
|
isUpdating={updateSettingMutation.isPending}
|
||||||
setting={setting}
|
isResetting={resetSettingMutation.isPending}
|
||||||
isFavorite={isFavorite(setting.key)}
|
isLast={idx === filteredSettings.length - 1}
|
||||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
/>
|
||||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
))}
|
||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
|
||||||
isUpdating={updateSettingMutation.isPending}
|
|
||||||
isResetting={resetSettingMutation.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show accordion for subcategories
|
// Normal mode: QuickToggles + settings by category
|
||||||
return (
|
const allCategorySettings = categories.flatMap((c) => c.settings);
|
||||||
<div className="space-y-3">
|
|
||||||
{categories.map((cat) => {
|
|
||||||
const isExpanded = expandedSections.has(cat.key);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cat.key}
|
|
||||||
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
|
|
||||||
>
|
|
||||||
{/* Accordion header */}
|
|
||||||
<button
|
|
||||||
onClick={() => toggleSection(cat.key)}
|
|
||||||
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="font-medium text-dark-100">{cat.label}</span>
|
|
||||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
|
||||||
{cat.settings.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Accordion content */}
|
if (allCategorySettings.length === 0) {
|
||||||
{isExpanded && (
|
return (
|
||||||
<div className="border-t border-dark-700/30 p-4 pt-0">
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||||
{cat.settings.map((setting) => (
|
</div>
|
||||||
<SettingRow
|
);
|
||||||
key={setting.key}
|
}
|
||||||
setting={setting}
|
|
||||||
isFavorite={isFavorite(setting.key)}
|
return (
|
||||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
<div>
|
||||||
onUpdate={(value) =>
|
<QuickToggles
|
||||||
updateSettingMutation.mutate({ key: setting.key, value })
|
settings={allCategorySettings}
|
||||||
}
|
onUpdate={(key, value) => updateSettingMutation.mutate({ key, value })}
|
||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
disabled={updateSettingMutation.isPending}
|
||||||
isUpdating={updateSettingMutation.isPending}
|
/>
|
||||||
isResetting={resetSettingMutation.isPending}
|
{categories.map((category) => {
|
||||||
/>
|
if (category.settings.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</div>
|
<div key={category.key} className="mb-4">
|
||||||
|
{categories.length > 1 && (
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-dark-200">{category.label}</h3>
|
||||||
|
<span className="text-xs text-dark-500">{category.settings.length}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-700/40">
|
||||||
|
{category.settings.map((setting, idx) => (
|
||||||
|
<SettingsTableRow
|
||||||
|
key={setting.key}
|
||||||
|
setting={setting}
|
||||||
|
isFavorite={isFavorite(setting.key)}
|
||||||
|
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||||
|
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||||
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
|
isUpdating={updateSettingMutation.isPending}
|
||||||
|
isResetting={resetSettingMutation.isPending}
|
||||||
|
isLast={idx === category.settings.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{categories.length === 0 && (
|
|
||||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
|
||||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||||
|
|
||||||
// Menu item types
|
// Tree sidebar types
|
||||||
export interface MenuItem {
|
export interface TreeSubItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeGroup {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
children: TreeSubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecialItem {
|
||||||
|
id: string;
|
||||||
|
icon?: string;
|
||||||
iconType?: 'star' | null;
|
iconType?: 'star' | null;
|
||||||
categories?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuSection {
|
export interface SettingsTreeConfig {
|
||||||
id: string;
|
specialItems: SpecialItem[];
|
||||||
items: MenuItem[];
|
groups: TreeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar menu configuration
|
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
|
||||||
export const MENU_SECTIONS: MenuSection[] = [
|
export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||||
{
|
specialItems: [
|
||||||
id: 'main',
|
{ id: 'favorites', iconType: 'star' },
|
||||||
items: [
|
{ id: 'branding', icon: '🎨' },
|
||||||
{ id: 'favorites', iconType: 'star' },
|
{ id: 'theme', icon: '🌈' },
|
||||||
{ id: 'branding', iconType: null },
|
{ id: 'analytics', icon: '📊' },
|
||||||
{ id: 'theme', iconType: null },
|
{ id: 'buttons', icon: '📱' },
|
||||||
{ id: 'analytics', iconType: null },
|
],
|
||||||
{ id: 'buttons', iconType: null },
|
groups: [
|
||||||
],
|
{
|
||||||
},
|
id: 'payments',
|
||||||
{
|
icon: '💳',
|
||||||
id: 'settings',
|
children: [
|
||||||
items: [
|
{ id: 'payments_general', categories: ['PAYMENT', 'PAYMENT_VERIFICATION'] },
|
||||||
{
|
{ id: 'payments_stars', categories: ['TELEGRAM'] },
|
||||||
id: 'payments',
|
{ id: 'payments_yookassa', categories: ['YOOKASSA'] },
|
||||||
iconType: null,
|
{ id: 'payments_cryptobot', categories: ['CRYPTOBOT'] },
|
||||||
categories: [
|
{ id: 'payments_cloudpayments', categories: ['CLOUDPAYMENTS'] },
|
||||||
'PAYMENT',
|
{ id: 'payments_freekassa', categories: ['FREEKASSA'] },
|
||||||
'PAYMENT_VERIFICATION',
|
{ id: 'payments_kassa_ai', categories: ['KASSA_AI'] },
|
||||||
'YOOKASSA',
|
{ id: 'payments_platega', categories: ['PLATEGA'] },
|
||||||
'CRYPTOBOT',
|
{ id: 'payments_pal24', categories: ['PAL24'] },
|
||||||
'HELEKET',
|
{ id: 'payments_heleket', categories: ['HELEKET'] },
|
||||||
'PLATEGA',
|
{ id: 'payments_mulenpay', categories: ['MULENPAY'] },
|
||||||
'TRIBUTE',
|
{ id: 'payments_tribute', categories: ['TRIBUTE'] },
|
||||||
'MULENPAY',
|
{ id: 'payments_wata', categories: ['WATA'] },
|
||||||
'PAL24',
|
{ id: 'payments_riopay', categories: ['RIOPAY'] },
|
||||||
'WATA',
|
{ id: 'payments_severpay', categories: ['SEVERPAY'] },
|
||||||
'TELEGRAM',
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'subscriptions',
|
||||||
id: 'subscriptions',
|
icon: '📦',
|
||||||
iconType: null,
|
children: [
|
||||||
categories: [
|
{ id: 'subs_core', categories: ['SUBSCRIPTIONS_CORE'] },
|
||||||
'SUBSCRIPTIONS_CORE',
|
{ id: 'subs_trial', categories: ['TRIAL'] },
|
||||||
'SIMPLE_SUBSCRIPTION',
|
{ id: 'subs_pricing', categories: ['SUBSCRIPTION_PRICES'] },
|
||||||
'PERIODS',
|
{ id: 'subs_periods', categories: ['PERIODS'] },
|
||||||
'SUBSCRIPTION_PRICES',
|
{ id: 'subs_traffic', categories: ['TRAFFIC', 'TRAFFIC_PACKAGES'] },
|
||||||
'TRAFFIC',
|
{ id: 'subs_simple', categories: ['SIMPLE_SUBSCRIPTION'] },
|
||||||
'TRAFFIC_PACKAGES',
|
{ id: 'subs_autopay', categories: ['AUTOPAY'] },
|
||||||
'TRIAL',
|
],
|
||||||
'AUTOPAY',
|
},
|
||||||
],
|
{
|
||||||
},
|
id: 'interface',
|
||||||
{
|
icon: '🖥️',
|
||||||
id: 'interface',
|
children: [
|
||||||
iconType: null,
|
{
|
||||||
categories: [
|
id: 'iface_general',
|
||||||
'INTERFACE',
|
categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'],
|
||||||
'INTERFACE_BRANDING',
|
},
|
||||||
'INTERFACE_SUBSCRIPTION',
|
{ id: 'iface_connect', categories: ['CONNECT_BUTTON'] },
|
||||||
'CONNECT_BUTTON',
|
{ id: 'iface_miniapp', categories: ['MINIAPP'] },
|
||||||
'MINIAPP',
|
{ id: 'iface_happ', categories: ['HAPP'] },
|
||||||
'TELEGRAM_WIDGET',
|
{ id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
|
||||||
'TELEGRAM_OIDC',
|
{ id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
|
||||||
'HAPP',
|
{ id: 'iface_skip', categories: ['SKIP'] },
|
||||||
'SKIP',
|
{ id: 'iface_additional', categories: ['ADDITIONAL'] },
|
||||||
'ADDITIONAL',
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'users',
|
||||||
id: 'notifications',
|
icon: '👥',
|
||||||
iconType: null,
|
children: [
|
||||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
{ id: 'users_support', categories: ['SUPPORT'] },
|
||||||
},
|
{ id: 'users_referral', categories: ['REFERRAL'] },
|
||||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
{ id: 'users_channel', categories: ['CHANNEL'] },
|
||||||
{
|
{ id: 'users_localization', categories: ['LOCALIZATION', 'TIMEZONE'] },
|
||||||
id: 'system',
|
{ id: 'users_moderation', categories: ['MODERATION', 'BAN_NOTIFICATIONS'] },
|
||||||
iconType: null,
|
],
|
||||||
categories: [
|
},
|
||||||
'CORE',
|
{
|
||||||
'REMNAWAVE',
|
id: 'notifications',
|
||||||
'SERVER_STATUS',
|
icon: '🔔',
|
||||||
'MONITORING',
|
children: [
|
||||||
'MAINTENANCE',
|
{ id: 'notif_user', categories: ['NOTIFICATIONS', 'WEBHOOK_NOTIFICATIONS'] },
|
||||||
'BACKUP',
|
{ id: 'notif_admin', categories: ['ADMIN_NOTIFICATIONS'] },
|
||||||
'VERSION',
|
{ id: 'notif_reports', categories: ['ADMIN_REPORTS'] },
|
||||||
'WEB_API',
|
],
|
||||||
'WEBHOOK',
|
},
|
||||||
'LOG',
|
{
|
||||||
'DEBUG',
|
id: 'database',
|
||||||
'EXTERNAL_ADMIN',
|
icon: '🗄️',
|
||||||
],
|
children: [
|
||||||
},
|
{ id: 'db_general', categories: ['DATABASE'] },
|
||||||
{
|
{ id: 'db_postgres', categories: ['POSTGRES'] },
|
||||||
id: 'users',
|
{ id: 'db_sqlite', categories: ['SQLITE'] },
|
||||||
iconType: null,
|
{ id: 'db_redis', categories: ['REDIS'] },
|
||||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
],
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
},
|
id: 'system',
|
||||||
];
|
icon: '⚙️',
|
||||||
|
children: [
|
||||||
|
{ id: 'sys_core', categories: ['CORE', 'DEBUG'] },
|
||||||
|
{ id: 'sys_remnawave', categories: ['REMNAWAVE'] },
|
||||||
|
{ id: 'sys_webapi', categories: ['WEB_API', 'EXTERNAL_ADMIN'] },
|
||||||
|
{ id: 'sys_webhook', categories: ['WEBHOOK'] },
|
||||||
|
{ id: 'sys_server', categories: ['SERVER_STATUS'] },
|
||||||
|
{ id: 'sys_monitoring', categories: ['MONITORING'] },
|
||||||
|
{ id: 'sys_maintenance', categories: ['MAINTENANCE'] },
|
||||||
|
{ id: 'sys_backup', categories: ['BACKUP'] },
|
||||||
|
{ id: 'sys_version', categories: ['VERSION'] },
|
||||||
|
{ id: 'sys_logging', categories: ['LOG'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: find which group and sub-item a backend category key belongs to
|
||||||
|
export function findTreeLocation(
|
||||||
|
categoryKey: string,
|
||||||
|
): { groupId: string; subItemId: string } | null {
|
||||||
|
for (const group of SETTINGS_TREE.groups) {
|
||||||
|
for (const child of group.children) {
|
||||||
|
if (child.categories.includes(categoryKey)) {
|
||||||
|
return { groupId: group.id, subItemId: child.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: get all backend category keys for a given sub-item id
|
||||||
|
export function getCategoriesForSubItem(subItemId: string): string[] {
|
||||||
|
for (const group of SETTINGS_TREE.groups) {
|
||||||
|
const child = group.children.find((c) => c.id === subItemId);
|
||||||
|
if (child) return child.categories;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Theme preset type
|
// Theme preset type
|
||||||
export interface ThemePreset {
|
export interface ThemePreset {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './icons';
|
|||||||
export * from './Toggle';
|
export * from './Toggle';
|
||||||
export * from './SettingInput';
|
export * from './SettingInput';
|
||||||
export * from './SettingRow';
|
export * from './SettingRow';
|
||||||
|
export * from './SettingsTableRow';
|
||||||
export * from './AnalyticsTab';
|
export * from './AnalyticsTab';
|
||||||
export * from './BrandingTab';
|
export * from './BrandingTab';
|
||||||
export * from './ButtonsTab';
|
export * from './ButtonsTab';
|
||||||
@@ -13,6 +14,8 @@ export * from './FavoritesTab';
|
|||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './SettingsMobileTabs';
|
export * from './SettingsMobileTabs';
|
||||||
export * from './SettingsSearch';
|
export * from './SettingsSearch';
|
||||||
|
export * from './SettingsTreeSidebar';
|
||||||
|
export * from './QuickToggles';
|
||||||
export * from './LocaleTabs';
|
export * from './LocaleTabs';
|
||||||
export * from './LocalizedInput';
|
export * from './LocalizedInput';
|
||||||
|
|
||||||
|
|||||||
@@ -1836,8 +1836,11 @@
|
|||||||
"title": "System Settings",
|
"title": "System Settings",
|
||||||
"allCategories": "All categories",
|
"allCategories": "All categories",
|
||||||
"noSettings": "No settings found",
|
"noSettings": "No settings found",
|
||||||
|
"quickToggles": "Quick Toggles",
|
||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"readOnly": "Read only",
|
"readOnly": "Read only",
|
||||||
|
"badgeDb": "DB",
|
||||||
|
"badgeEnv": "ENV",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"categoriesCount": "categories",
|
"categoriesCount": "categories",
|
||||||
"settingsCount": "settings",
|
"settingsCount": "settings",
|
||||||
@@ -1910,6 +1913,84 @@
|
|||||||
"quickPresets": "Quick presets",
|
"quickPresets": "Quick presets",
|
||||||
"resetAllColors": "Reset all colors",
|
"resetAllColors": "Reset all colors",
|
||||||
"statusColors": "Status colors",
|
"statusColors": "Status colors",
|
||||||
|
"favorites": "Favorites",
|
||||||
|
"branding": "Branding",
|
||||||
|
"theme": "Theme",
|
||||||
|
"payments": "Payments",
|
||||||
|
"subscriptions": "Subscriptions",
|
||||||
|
"interface": "Interface",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"database": "Database",
|
||||||
|
"system": "System",
|
||||||
|
"users": "Users",
|
||||||
|
"customization": "Customization",
|
||||||
|
"settingsLabel": "Settings",
|
||||||
|
"backToAdmin": "Back to admin",
|
||||||
|
"totalCount": "{{count}} total",
|
||||||
|
"modifiedCount": "{{count}} modified",
|
||||||
|
"groups": {
|
||||||
|
"payments": "Payments",
|
||||||
|
"subscriptions": "Subscriptions",
|
||||||
|
"interface": "Interface",
|
||||||
|
"users": "Users",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"database": "Database",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"payments_general": "General",
|
||||||
|
"payments_stars": "Telegram Stars",
|
||||||
|
"payments_yookassa": "YooKassa",
|
||||||
|
"payments_cryptobot": "CryptoBot",
|
||||||
|
"payments_cloudpayments": "CloudPayments",
|
||||||
|
"payments_freekassa": "FreeKassa",
|
||||||
|
"payments_kassa_ai": "Kassa AI",
|
||||||
|
"payments_platega": "Platega",
|
||||||
|
"payments_pal24": "PAL24",
|
||||||
|
"payments_heleket": "Heleket",
|
||||||
|
"payments_mulenpay": "MulenPay",
|
||||||
|
"payments_tribute": "Tribute",
|
||||||
|
"payments_wata": "Wata",
|
||||||
|
"payments_riopay": "RioPay",
|
||||||
|
"payments_severpay": "SeverPay",
|
||||||
|
"subs_core": "Core",
|
||||||
|
"subs_trial": "Trial",
|
||||||
|
"subs_pricing": "Pricing",
|
||||||
|
"subs_periods": "Periods",
|
||||||
|
"subs_traffic": "Traffic",
|
||||||
|
"subs_simple": "Simple subscription",
|
||||||
|
"subs_autopay": "Auto-pay",
|
||||||
|
"iface_general": "General",
|
||||||
|
"iface_connect": "Connect button",
|
||||||
|
"iface_miniapp": "MiniApp",
|
||||||
|
"iface_happ": "HAPP",
|
||||||
|
"iface_widget": "Login Widget",
|
||||||
|
"iface_oidc": "OIDC",
|
||||||
|
"iface_skip": "Skip steps",
|
||||||
|
"iface_additional": "Additional",
|
||||||
|
"users_support": "Support",
|
||||||
|
"users_referral": "Referral",
|
||||||
|
"users_channel": "Channel",
|
||||||
|
"users_localization": "Localization",
|
||||||
|
"users_moderation": "Moderation",
|
||||||
|
"notif_user": "User notifications",
|
||||||
|
"notif_admin": "Admin notifications",
|
||||||
|
"notif_reports": "Reports",
|
||||||
|
"db_general": "General",
|
||||||
|
"db_postgres": "PostgreSQL",
|
||||||
|
"db_sqlite": "SQLite",
|
||||||
|
"db_redis": "Redis",
|
||||||
|
"sys_core": "Core & Debug",
|
||||||
|
"sys_remnawave": "RemnaWave",
|
||||||
|
"sys_webapi": "Web API",
|
||||||
|
"sys_webhook": "Webhook",
|
||||||
|
"sys_server": "Server status",
|
||||||
|
"sys_monitoring": "Monitoring",
|
||||||
|
"sys_maintenance": "Maintenance",
|
||||||
|
"sys_backup": "Backup",
|
||||||
|
"sys_version": "Version",
|
||||||
|
"sys_logging": "Logging"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
|||||||
@@ -1497,6 +1497,7 @@
|
|||||||
"title": "تنظیمات سیستم",
|
"title": "تنظیمات سیستم",
|
||||||
"allCategories": "همه دستهها",
|
"allCategories": "همه دستهها",
|
||||||
"noSettings": "تنظیماتی یافت نشد",
|
"noSettings": "تنظیماتی یافت نشد",
|
||||||
|
"quickToggles": "تغییر سریع",
|
||||||
"modified": "تغییر یافته",
|
"modified": "تغییر یافته",
|
||||||
"readOnly": "فقط خواندنی",
|
"readOnly": "فقط خواندنی",
|
||||||
"reset": "بازنشانی",
|
"reset": "بازنشانی",
|
||||||
@@ -1571,6 +1572,86 @@
|
|||||||
"quickPresets": "پیشتنظیمهای سریع",
|
"quickPresets": "پیشتنظیمهای سریع",
|
||||||
"resetAllColors": "بازنشانی همه رنگها",
|
"resetAllColors": "بازنشانی همه رنگها",
|
||||||
"statusColors": "رنگهای وضعیت",
|
"statusColors": "رنگهای وضعیت",
|
||||||
|
"favorites": "علاقهمندیها",
|
||||||
|
"branding": "برندسازی",
|
||||||
|
"theme": "پوسته",
|
||||||
|
"payments": "پرداختها",
|
||||||
|
"subscriptions": "اشتراکها",
|
||||||
|
"interface": "رابط کاربری",
|
||||||
|
"notifications": "اعلانها",
|
||||||
|
"database": "پایگاه داده",
|
||||||
|
"system": "سیستم",
|
||||||
|
"users": "کاربران",
|
||||||
|
"customization": "سفارشیسازی",
|
||||||
|
"settingsLabel": "تنظیمات",
|
||||||
|
"backToAdmin": "بازگشت به مدیریت",
|
||||||
|
"totalCount": "{{count}} مورد",
|
||||||
|
"modifiedCount": "{{count}} تغییر یافته",
|
||||||
|
"badgeDb": "پایگاه داده",
|
||||||
|
"badgeEnv": "متغیر محیطی",
|
||||||
|
"groups": {
|
||||||
|
"payments": "پرداختها",
|
||||||
|
"subscriptions": "اشتراکها",
|
||||||
|
"interface": "رابط کاربری",
|
||||||
|
"users": "کاربران",
|
||||||
|
"notifications": "اعلانها",
|
||||||
|
"database": "پایگاه داده",
|
||||||
|
"system": "سیستم"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"payments_general": "عمومی",
|
||||||
|
"payments_stars": "Telegram Stars",
|
||||||
|
"payments_yookassa": "YooKassa",
|
||||||
|
"payments_cryptobot": "CryptoBot",
|
||||||
|
"payments_cloudpayments": "CloudPayments",
|
||||||
|
"payments_freekassa": "FreeKassa",
|
||||||
|
"payments_kassa_ai": "Kassa AI",
|
||||||
|
"payments_platega": "Platega",
|
||||||
|
"payments_pal24": "PAL24",
|
||||||
|
"payments_heleket": "Heleket",
|
||||||
|
"payments_mulenpay": "MulenPay",
|
||||||
|
"payments_tribute": "Tribute",
|
||||||
|
"payments_wata": "Wata",
|
||||||
|
"payments_riopay": "RioPay",
|
||||||
|
"payments_severpay": "SeverPay",
|
||||||
|
"subs_core": "هسته",
|
||||||
|
"subs_trial": "آزمایشی",
|
||||||
|
"subs_pricing": "قیمتگذاری",
|
||||||
|
"subs_periods": "دورهها",
|
||||||
|
"subs_traffic": "ترافیک",
|
||||||
|
"subs_simple": "اشتراک ساده",
|
||||||
|
"subs_autopay": "پرداخت خودکار",
|
||||||
|
"iface_general": "عمومی",
|
||||||
|
"iface_connect": "دکمه اتصال",
|
||||||
|
"iface_miniapp": "MiniApp",
|
||||||
|
"iface_happ": "HAPP",
|
||||||
|
"iface_widget": "ویجت ورود",
|
||||||
|
"iface_oidc": "OIDC",
|
||||||
|
"iface_skip": "رد کردن مراحل",
|
||||||
|
"iface_additional": "اضافی",
|
||||||
|
"users_support": "پشتیبانی",
|
||||||
|
"users_referral": "معرفی",
|
||||||
|
"users_channel": "کانال",
|
||||||
|
"users_localization": "بومیسازی",
|
||||||
|
"users_moderation": "مدیریت محتوا",
|
||||||
|
"notif_user": "اعلانهای کاربر",
|
||||||
|
"notif_admin": "اعلانهای مدیر",
|
||||||
|
"notif_reports": "گزارشها",
|
||||||
|
"db_general": "عمومی",
|
||||||
|
"db_postgres": "PostgreSQL",
|
||||||
|
"db_sqlite": "SQLite",
|
||||||
|
"db_redis": "Redis",
|
||||||
|
"sys_core": "هسته و اشکالزدایی",
|
||||||
|
"sys_remnawave": "RemnaWave",
|
||||||
|
"sys_webapi": "Web API",
|
||||||
|
"sys_webhook": "Webhook",
|
||||||
|
"sys_server": "وضعیت سرور",
|
||||||
|
"sys_monitoring": "نظارت",
|
||||||
|
"sys_maintenance": "نگهداری",
|
||||||
|
"sys_backup": "پشتیبانگیری",
|
||||||
|
"sys_version": "نسخه",
|
||||||
|
"sys_logging": "لاگها"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
|||||||
@@ -1861,8 +1861,11 @@
|
|||||||
"title": "Настройки системы",
|
"title": "Настройки системы",
|
||||||
"allCategories": "Все категории",
|
"allCategories": "Все категории",
|
||||||
"noSettings": "Настройки не найдены",
|
"noSettings": "Настройки не найдены",
|
||||||
|
"quickToggles": "Быстрые переключатели",
|
||||||
"modified": "Изменено",
|
"modified": "Изменено",
|
||||||
"readOnly": "Только чтение",
|
"readOnly": "Только чтение",
|
||||||
|
"badgeDb": "БД",
|
||||||
|
"badgeEnv": "ENV",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"categoriesCount": "категорий",
|
"categoriesCount": "категорий",
|
||||||
"settingsCount": "настроек",
|
"settingsCount": "настроек",
|
||||||
@@ -1936,6 +1939,74 @@
|
|||||||
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
|
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
|
||||||
"googleLabelHint": "Метка конверсии для отслеживания событий",
|
"googleLabelHint": "Метка конверсии для отслеживания событий",
|
||||||
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
|
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
|
||||||
|
"customization": "Кастомизация",
|
||||||
|
"settingsLabel": "Настройки",
|
||||||
|
"backToAdmin": "Назад к админке",
|
||||||
|
"totalCount": "{{count}} всего",
|
||||||
|
"modifiedCount": "{{count}} изменено",
|
||||||
|
"groups": {
|
||||||
|
"payments": "Платежи",
|
||||||
|
"subscriptions": "Подписки",
|
||||||
|
"interface": "Интерфейс",
|
||||||
|
"users": "Пользователи",
|
||||||
|
"notifications": "Уведомления",
|
||||||
|
"database": "База данных",
|
||||||
|
"system": "Система"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"payments_general": "Основные",
|
||||||
|
"payments_stars": "Telegram Stars",
|
||||||
|
"payments_yookassa": "YooKassa",
|
||||||
|
"payments_cryptobot": "CryptoBot",
|
||||||
|
"payments_cloudpayments": "CloudPayments",
|
||||||
|
"payments_freekassa": "FreeKassa",
|
||||||
|
"payments_kassa_ai": "Kassa AI",
|
||||||
|
"payments_platega": "Platega",
|
||||||
|
"payments_pal24": "PAL24",
|
||||||
|
"payments_heleket": "Heleket",
|
||||||
|
"payments_mulenpay": "MulenPay",
|
||||||
|
"payments_tribute": "Tribute",
|
||||||
|
"payments_wata": "Wata",
|
||||||
|
"payments_riopay": "RioPay",
|
||||||
|
"payments_severpay": "SeverPay",
|
||||||
|
"subs_core": "Основные",
|
||||||
|
"subs_trial": "Пробный период",
|
||||||
|
"subs_pricing": "Цены",
|
||||||
|
"subs_periods": "Периоды",
|
||||||
|
"subs_traffic": "Трафик",
|
||||||
|
"subs_simple": "Простая подписка",
|
||||||
|
"subs_autopay": "Автоплатёж",
|
||||||
|
"iface_general": "Основные",
|
||||||
|
"iface_connect": "Кнопка подключения",
|
||||||
|
"iface_miniapp": "MiniApp",
|
||||||
|
"iface_happ": "HAPP",
|
||||||
|
"iface_widget": "Виджет входа",
|
||||||
|
"iface_oidc": "OIDC",
|
||||||
|
"iface_skip": "Пропуск шагов",
|
||||||
|
"iface_additional": "Дополнительно",
|
||||||
|
"users_support": "Поддержка",
|
||||||
|
"users_referral": "Реферальная система",
|
||||||
|
"users_channel": "Канал",
|
||||||
|
"users_localization": "Локализация",
|
||||||
|
"users_moderation": "Модерация",
|
||||||
|
"notif_user": "Уведомления пользователей",
|
||||||
|
"notif_admin": "Уведомления админов",
|
||||||
|
"notif_reports": "Отчёты",
|
||||||
|
"db_general": "Основные",
|
||||||
|
"db_postgres": "PostgreSQL",
|
||||||
|
"db_sqlite": "SQLite",
|
||||||
|
"db_redis": "Redis",
|
||||||
|
"sys_core": "Ядро и отладка",
|
||||||
|
"sys_remnawave": "RemnaWave",
|
||||||
|
"sys_webapi": "Web API",
|
||||||
|
"sys_webhook": "Webhook",
|
||||||
|
"sys_server": "Статус сервера",
|
||||||
|
"sys_monitoring": "Мониторинг",
|
||||||
|
"sys_maintenance": "Обслуживание",
|
||||||
|
"sys_backup": "Бэкап",
|
||||||
|
"sys_version": "Версия",
|
||||||
|
"sys_logging": "Логи"
|
||||||
|
},
|
||||||
"presets": {
|
"presets": {
|
||||||
"standard": "Стандарт",
|
"standard": "Стандарт",
|
||||||
"ocean": "Океан",
|
"ocean": "Океан",
|
||||||
|
|||||||
@@ -1535,6 +1535,7 @@
|
|||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
"allCategories": "所有分类",
|
"allCategories": "所有分类",
|
||||||
"noSettings": "未找到设置",
|
"noSettings": "未找到设置",
|
||||||
|
"quickToggles": "快速开关",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
"readOnly": "只读",
|
"readOnly": "只读",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
@@ -1609,6 +1610,86 @@
|
|||||||
"quickPresets": "快速预设",
|
"quickPresets": "快速预设",
|
||||||
"resetAllColors": "重置所有颜色",
|
"resetAllColors": "重置所有颜色",
|
||||||
"statusColors": "状态颜色",
|
"statusColors": "状态颜色",
|
||||||
|
"favorites": "收藏",
|
||||||
|
"branding": "品牌",
|
||||||
|
"theme": "主题",
|
||||||
|
"payments": "支付",
|
||||||
|
"subscriptions": "订阅",
|
||||||
|
"interface": "界面",
|
||||||
|
"notifications": "通知",
|
||||||
|
"database": "数据库",
|
||||||
|
"system": "系统",
|
||||||
|
"users": "用户",
|
||||||
|
"customization": "自定义",
|
||||||
|
"settingsLabel": "设置",
|
||||||
|
"backToAdmin": "返回管理面板",
|
||||||
|
"totalCount": "共 {{count}} 项",
|
||||||
|
"modifiedCount": "已修改 {{count}} 项",
|
||||||
|
"badgeDb": "数据库",
|
||||||
|
"badgeEnv": "环境变量",
|
||||||
|
"groups": {
|
||||||
|
"payments": "支付",
|
||||||
|
"subscriptions": "订阅",
|
||||||
|
"interface": "界面",
|
||||||
|
"users": "用户",
|
||||||
|
"notifications": "通知",
|
||||||
|
"database": "数据库",
|
||||||
|
"system": "系统"
|
||||||
|
},
|
||||||
|
"tree": {
|
||||||
|
"payments_general": "通用",
|
||||||
|
"payments_stars": "Telegram Stars",
|
||||||
|
"payments_yookassa": "YooKassa",
|
||||||
|
"payments_cryptobot": "CryptoBot",
|
||||||
|
"payments_cloudpayments": "CloudPayments",
|
||||||
|
"payments_freekassa": "FreeKassa",
|
||||||
|
"payments_kassa_ai": "Kassa AI",
|
||||||
|
"payments_platega": "Platega",
|
||||||
|
"payments_pal24": "PAL24",
|
||||||
|
"payments_heleket": "Heleket",
|
||||||
|
"payments_mulenpay": "MulenPay",
|
||||||
|
"payments_tribute": "Tribute",
|
||||||
|
"payments_wata": "Wata",
|
||||||
|
"payments_riopay": "RioPay",
|
||||||
|
"payments_severpay": "SeverPay",
|
||||||
|
"subs_core": "核心",
|
||||||
|
"subs_trial": "试用",
|
||||||
|
"subs_pricing": "定价",
|
||||||
|
"subs_periods": "周期",
|
||||||
|
"subs_traffic": "流量",
|
||||||
|
"subs_simple": "简单订阅",
|
||||||
|
"subs_autopay": "自动支付",
|
||||||
|
"iface_general": "通用",
|
||||||
|
"iface_connect": "连接按钮",
|
||||||
|
"iface_miniapp": "MiniApp",
|
||||||
|
"iface_happ": "HAPP",
|
||||||
|
"iface_widget": "登录小部件",
|
||||||
|
"iface_oidc": "OIDC",
|
||||||
|
"iface_skip": "跳过步骤",
|
||||||
|
"iface_additional": "附加",
|
||||||
|
"users_support": "支持",
|
||||||
|
"users_referral": "推荐",
|
||||||
|
"users_channel": "频道",
|
||||||
|
"users_localization": "本地化",
|
||||||
|
"users_moderation": "审核",
|
||||||
|
"notif_user": "用户通知",
|
||||||
|
"notif_admin": "管理员通知",
|
||||||
|
"notif_reports": "报告",
|
||||||
|
"db_general": "通用",
|
||||||
|
"db_postgres": "PostgreSQL",
|
||||||
|
"db_sqlite": "SQLite",
|
||||||
|
"db_redis": "Redis",
|
||||||
|
"sys_core": "核心与调试",
|
||||||
|
"sys_remnawave": "RemnaWave",
|
||||||
|
"sys_webapi": "Web API",
|
||||||
|
"sys_webhook": "Webhook",
|
||||||
|
"sys_server": "服务器状态",
|
||||||
|
"sys_monitoring": "监控",
|
||||||
|
"sys_maintenance": "维护",
|
||||||
|
"sys_backup": "备份",
|
||||||
|
"sys_version": "版本",
|
||||||
|
"sys_logging": "日志"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||||
import { themeColorsApi } from '../api/themeColors';
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||||
import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||||
@@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab';
|
|||||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||||
|
import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar';
|
||||||
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
||||||
import {
|
import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch';
|
||||||
SettingsSearch,
|
|
||||||
SettingsSearchMobile,
|
|
||||||
SettingsSearchResults,
|
|
||||||
} from '../components/admin/SettingsSearch';
|
|
||||||
|
|
||||||
// BackIcon
|
// BackIcon
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
@@ -33,17 +30,18 @@ const BackIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find section ID by category key
|
// ChevronRight for breadcrumbs
|
||||||
function findSectionByCategory(categoryKey: string): string | null {
|
const ChevronRightIcon = () => (
|
||||||
for (const section of MENU_SECTIONS) {
|
<svg
|
||||||
for (const item of section.items) {
|
className="h-3.5 w-3.5 text-dark-600"
|
||||||
if (item.categories?.includes(categoryKey)) {
|
fill="none"
|
||||||
return item.id;
|
viewBox="0 0 24 24"
|
||||||
}
|
stroke="currentColor"
|
||||||
}
|
strokeWidth={2}
|
||||||
}
|
>
|
||||||
return null;
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
}
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -73,23 +71,29 @@ export default function AdminSettings() {
|
|||||||
queryFn: () => adminSettingsApi.getSettings(),
|
queryFn: () => adminSettingsApi.getSettings(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current menu item configuration
|
// Find active tree info (group + child for tree sub-items)
|
||||||
const currentMenuItem = useMemo(() => {
|
// No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial
|
||||||
for (const section of MENU_SECTIONS) {
|
let activeTreeInfo: {
|
||||||
const item = section.items.find((i: MenuItem) => i.id === activeSection);
|
group: (typeof SETTINGS_TREE.groups)[number];
|
||||||
if (item) return item;
|
child: (typeof SETTINGS_TREE.groups)[number]['children'][number];
|
||||||
|
} | null = null;
|
||||||
|
for (const group of SETTINGS_TREE.groups) {
|
||||||
|
const child = group.children.find((c) => c.id === activeSection);
|
||||||
|
if (child) {
|
||||||
|
activeTreeInfo = { group, child };
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
}, [activeSection]);
|
|
||||||
|
|
||||||
// Get categories for current section
|
// Get categories for current sub-item
|
||||||
const currentCategories = useMemo(() => {
|
const currentCategories = useMemo(() => {
|
||||||
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
|
if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return [];
|
||||||
|
|
||||||
|
const categoryKeys = activeTreeInfo.child.categories;
|
||||||
const categoryMap = new Map<string, SettingDefinition[]>();
|
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||||
|
|
||||||
for (const setting of allSettings) {
|
for (const setting of allSettings) {
|
||||||
if (currentMenuItem.categories.includes(setting.category.key)) {
|
if (categoryKeys.includes(setting.category.key)) {
|
||||||
if (!categoryMap.has(setting.category.key)) {
|
if (!categoryMap.has(setting.category.key)) {
|
||||||
categoryMap.set(setting.category.key, []);
|
categoryMap.set(setting.category.key, []);
|
||||||
}
|
}
|
||||||
@@ -102,7 +106,7 @@ export default function AdminSettings() {
|
|||||||
label: t(`admin.settings.categories.${key}`, key),
|
label: t(`admin.settings.categories.${key}`, key),
|
||||||
settings,
|
settings,
|
||||||
}));
|
}));
|
||||||
}, [currentMenuItem, allSettings, t]);
|
}, [activeTreeInfo, allSettings, t]);
|
||||||
|
|
||||||
// Filter settings for search - GLOBAL search across all settings
|
// Filter settings for search - GLOBAL search across all settings
|
||||||
const filteredSettings = useMemo(() => {
|
const filteredSettings = useMemo(() => {
|
||||||
@@ -112,24 +116,14 @@ export default function AdminSettings() {
|
|||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
|
||||||
return allSettings.filter((s: SettingDefinition) => {
|
return allSettings.filter((s: SettingDefinition) => {
|
||||||
// Search by key
|
|
||||||
if (s.key.toLowerCase().includes(q)) return true;
|
if (s.key.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by original name
|
|
||||||
if (s.name?.toLowerCase().includes(q)) return true;
|
if (s.name?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by translated name
|
|
||||||
const formattedKey = formatSettingKey(s.name || s.key);
|
const formattedKey = formatSettingKey(s.name || s.key);
|
||||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
if (translatedName.toLowerCase().includes(q)) return true;
|
if (translatedName.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by description
|
|
||||||
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by category
|
|
||||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||||
if (categoryLabel.toLowerCase().includes(q)) return true;
|
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [allSettings, searchQuery, t]);
|
}, [allSettings, searchQuery, t]);
|
||||||
@@ -140,19 +134,42 @@ export default function AdminSettings() {
|
|||||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||||
}, [allSettings, favorites]);
|
}, [allSettings, favorites]);
|
||||||
|
|
||||||
|
// Count of modified settings
|
||||||
|
const modifiedCount = useMemo(() => {
|
||||||
|
if (!allSettings || !Array.isArray(allSettings)) return 0;
|
||||||
|
return allSettings.filter((s: SettingDefinition) => s.has_override).length;
|
||||||
|
}, [allSettings]);
|
||||||
|
|
||||||
|
// Total settings count
|
||||||
|
const totalCount = useMemo(() => {
|
||||||
|
if (!allSettings || !Array.isArray(allSettings)) return 0;
|
||||||
|
return allSettings.length;
|
||||||
|
}, [allSettings]);
|
||||||
|
|
||||||
// Handle setting selection from autocomplete
|
// Handle setting selection from autocomplete
|
||||||
const handleSelectSetting = useCallback(
|
const handleSelectSetting = useCallback(
|
||||||
(setting: SettingDefinition) => {
|
(setting: SettingDefinition) => {
|
||||||
const sectionId = findSectionByCategory(setting.category.key);
|
const location = findTreeLocation(setting.category.key);
|
||||||
if (sectionId) {
|
if (location) {
|
||||||
setActiveSection(sectionId);
|
setActiveSection(location.subItemId);
|
||||||
}
|
}
|
||||||
// Set search to setting key to filter to just this setting
|
|
||||||
setSearchQuery(setting.key);
|
setSearchQuery(setting.key);
|
||||||
},
|
},
|
||||||
[setActiveSection, setSearchQuery],
|
[setActiveSection, setSearchQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get the display title for the current section
|
||||||
|
const sectionTitle = useMemo(() => {
|
||||||
|
// Special items
|
||||||
|
const specialItem = SETTINGS_TREE.specialItems.find((item) => item.id === activeSection);
|
||||||
|
if (specialItem) return t(`admin.settings.${specialItem.id}`);
|
||||||
|
|
||||||
|
// Tree sub-items
|
||||||
|
if (activeTreeInfo) return t(`admin.settings.tree.${activeTreeInfo.child.id}`);
|
||||||
|
|
||||||
|
return t('admin.settings.title');
|
||||||
|
}, [activeSection, activeTreeInfo, t]);
|
||||||
|
|
||||||
// Render content based on active section
|
// Render content based on active section
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// If searching, always show search results regardless of active section
|
// If searching, always show search results regardless of active section
|
||||||
@@ -186,17 +203,7 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
if (
|
if (activeTreeInfo) {
|
||||||
[
|
|
||||||
'payments',
|
|
||||||
'subscriptions',
|
|
||||||
'interface',
|
|
||||||
'notifications',
|
|
||||||
'database',
|
|
||||||
'system',
|
|
||||||
'users',
|
|
||||||
].includes(activeSection)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<SettingsTab
|
<SettingsTab
|
||||||
categories={currentCategories}
|
categories={currentCategories}
|
||||||
@@ -233,7 +240,7 @@ export default function AdminSettings() {
|
|||||||
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
||||||
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
||||||
{/* Fixed Sidebar */}
|
{/* Fixed Sidebar */}
|
||||||
<div className="w-64 shrink-0 overflow-y-auto border-r border-dark-700/50">
|
<div className="w-[264px] shrink-0 overflow-y-auto border-r border-dark-700/50">
|
||||||
<div className="border-b border-dark-700/50 p-4">
|
<div className="border-b border-dark-700/50 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Show back button only on web, not in Telegram Mini App */}
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
@@ -241,6 +248,7 @@ export default function AdminSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => navigate('/admin')}
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
aria-label={t('admin.settings.backToAdmin')}
|
||||||
>
|
>
|
||||||
<BackIcon />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -248,67 +256,52 @@ export default function AdminSettings() {
|
|||||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1 p-2">
|
<SettingsTreeSidebar
|
||||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
activeSection={activeSection}
|
||||||
<div key={section.id}>
|
onSectionChange={setActiveSection}
|
||||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
favoritesCount={favorites.length}
|
||||||
{section.items.map((item) => {
|
searchQuery={searchQuery}
|
||||||
const isActive = activeSection === item.id;
|
onSearchChange={setSearchQuery}
|
||||||
const hasIcon = item.iconType === 'star';
|
allSettings={allSettings}
|
||||||
return (
|
onSelectSetting={handleSelectSetting}
|
||||||
<button
|
/>
|
||||||
key={item.id}
|
|
||||||
onClick={() => setActiveSection(item.id)}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
|
||||||
isActive
|
|
||||||
? 'bg-accent-500/10 text-accent-400'
|
|
||||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{hasIcon && (
|
|
||||||
<svg
|
|
||||||
className={`h-4 w-4 ${isActive ? 'fill-current' : ''}`}
|
|
||||||
fill={isActive ? 'currentColor' : 'none'}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
|
||||||
{item.id === 'favorites' && favorites.length > 0 && (
|
|
||||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
|
||||||
{favorites.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
||||||
|
{/* Breadcrumb for tree sub-items */}
|
||||||
|
{activeTreeInfo && !searchQuery.trim() && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveSection(activeTreeInfo.group.children[0].id)}
|
||||||
|
className="text-dark-500 transition-colors hover:text-dark-300"
|
||||||
|
>
|
||||||
|
{t(`admin.settings.groups.${activeTreeInfo.group.id}`)}
|
||||||
|
</button>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
<span className="text-dark-300">
|
||||||
|
{t(`admin.settings.tree.${activeTreeInfo.child.id}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title + count badges */}
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<h2 className="truncate text-xl font-semibold text-dark-100">
|
<h2 className="truncate text-xl font-semibold text-dark-100">{sectionTitle}</h2>
|
||||||
{t(`admin.settings.${activeSection}`)}
|
{totalCount > 0 && !searchQuery.trim() && activeTreeInfo && (
|
||||||
</h2>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1" />
|
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||||
<SettingsSearch
|
{t('admin.settings.totalCount', { count: totalCount })}
|
||||||
searchQuery={searchQuery}
|
</span>
|
||||||
setSearchQuery={setSearchQuery}
|
{modifiedCount > 0 && (
|
||||||
resultsCount={filteredSettings.length}
|
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||||
allSettings={allSettings}
|
{t('admin.settings.modifiedCount', { count: modifiedCount })}
|
||||||
onSelectSetting={handleSelectSetting}
|
</span>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user