Improve admin settings UI components

- Add EditIcon component to icons.tsx
- Enhance SettingInput with textarea for long/JSON values
- Improve SettingRow styling and long value handling
- Fix CORE category translation (Ядро → Бот)
- Add auto-resize for textarea and Ctrl+Enter save shortcut
This commit is contained in:
PEDZEO
2026-01-21 04:21:25 +03:00
parent 7eb42d2cf7
commit b359e6445c
6 changed files with 226 additions and 55 deletions

View File

@@ -41,7 +41,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
} }
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{settings.map((setting) => ( {settings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState, useRef, useEffect } from 'react'
import { SettingDefinition } from '../../api/adminSettings' import { SettingDefinition } from '../../api/adminSettings'
import { CheckIcon, CloseIcon } from './icons' import { CheckIcon, CloseIcon, EditIcon } from './icons'
interface SettingInputProps { interface SettingInputProps {
setting: SettingDefinition setting: SettingDefinition
@@ -8,12 +8,48 @@ interface SettingInputProps {
disabled?: boolean disabled?: boolean
} }
// Check if value is likely JSON or multi-line
function isLongValue(value: string | null | undefined): boolean {
if (!value) return false
const str = String(value)
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{')
}
// Check if key suggests it's a list or JSON config
function isListOrJsonKey(key: string): boolean {
const lowerKey = key.toLowerCase()
return (
lowerKey.includes('_items') ||
lowerKey.includes('_config') ||
lowerKey.includes('_keywords') ||
lowerKey.includes('_list') ||
lowerKey.includes('_json') ||
lowerKey.includes('_template') ||
lowerKey.includes('_periods') ||
lowerKey.includes('_discounts') ||
lowerKey.includes('_packages')
)
}
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) { export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [value, setValue] = useState('') const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const currentValue = String(setting.current ?? '')
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key)
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current && isEditing) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px'
}
}, [value, isEditing])
const handleStart = () => { const handleStart = () => {
setValue(String(setting.current ?? '')) setValue(currentValue)
setIsEditing(true) setIsEditing(true)
} }
@@ -31,10 +67,10 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
if (setting.choices && setting.choices.length > 0) { if (setting.choices && setting.choices.length > 0) {
return ( return (
<select <select
value={String(setting.current ?? '')} value={currentValue}
onChange={(e) => onUpdate(e.target.value)} onChange={(e) => onUpdate(e.target.value)}
disabled={disabled} disabled={disabled}
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50" className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer"
> >
{setting.choices.map((choice, idx) => ( {setting.choices.map((choice, idx) => (
<option key={idx} value={String(choice.value)}> <option key={idx} value={String(choice.value)}>
@@ -45,11 +81,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
) )
} }
// Editing mode // Editing mode - Textarea for long values
if (isEditing && needsTextarea) {
return (
<div className="w-full space-y-2">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') handleCancel()
// Ctrl+Enter to save
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave()
}}
autoFocus
placeholder="Введите значение..."
className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]"
/>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
<div className="flex items-center gap-2">
<button
onClick={handleCancel}
className="px-3 py-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors text-sm"
>
Отмена
</button>
<button
onClick={handleSave}
className="px-3 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors text-sm flex items-center gap-1.5"
>
<CheckIcon />
Сохранить
</button>
</div>
</div>
</div>
)
}
// Editing mode - Regular input
if (isEditing) { if (isEditing) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
ref={inputRef}
type={setting.type === 'int' || setting.type === 'float' ? 'number' : 'text'} type={setting.type === 'int' || setting.type === 'float' ? 'number' : 'text'}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
@@ -58,17 +134,20 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
if (e.key === 'Escape') handleCancel() if (e.key === 'Escape') handleCancel()
}} }}
autoFocus autoFocus
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-1.5 text-sm text-dark-100 focus:outline-none w-32" placeholder="Введите значение..."
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56"
/> />
<button <button
onClick={handleSave} onClick={handleSave}
className="p-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors" className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
title="Сохранить (Enter)"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={handleCancel} onClick={handleCancel}
className="p-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors" className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
title="Отмена (Esc)"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
@@ -76,14 +155,38 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
) )
} }
// Display mode // Display mode - Long value preview
if (needsTextarea) {
const displayValue = currentValue || '-'
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue
return ( return (
<button <button
onClick={handleStart} onClick={handleStart}
disabled={disabled} disabled={disabled}
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm text-dark-200 hover:border-dark-500 transition-colors disabled:opacity-50 min-w-[80px] text-left font-mono truncate max-w-[150px]" className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-700 transition-colors disabled:opacity-50 text-left font-mono group"
> >
{String(setting.current ?? '-')} <div className="flex items-start justify-between gap-2">
<span className="break-all line-clamp-2 flex-1">{previewValue}</span>
<span className="text-dark-500 group-hover:text-accent-400 transition-colors flex-shrink-0">
<EditIcon />
</span>
</div>
</button>
)
}
// Display mode - Short value
return (
<button
onClick={handleStart}
disabled={disabled}
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-600 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono truncate max-w-[200px] flex items-center gap-2 group"
>
<span className="truncate flex-1">{currentValue || '-'}</span>
<span className="text-dark-500 group-hover:text-accent-400 transition-colors opacity-0 group-hover:opacity-100">
<EditIcon />
</span>
</button> </button>
) )
} }

View File

@@ -30,68 +30,93 @@ export function SettingRow({
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey) const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null const description = setting.hint?.description ? stripHtml(setting.hint.description) : null
// Check if this is a long/complex value
const isLongValue = (() => {
const val = String(setting.current ?? '')
const key = setting.key.toLowerCase()
return ( return (
<div className="group p-4 rounded-xl bg-dark-800/30 border border-dark-700/30 hover:border-dark-600/50 transition-all"> val.length > 50 ||
{/* Top row - name and badge */} val.includes('\n') ||
<div className="flex items-start justify-between gap-3 mb-2"> val.startsWith('[') ||
val.startsWith('{') ||
key.includes('_items') ||
key.includes('_config') ||
key.includes('_keywords') ||
key.includes('_template') ||
key.includes('_packages')
)
})()
return (
<div className="group p-4 sm:p-5 rounded-2xl bg-dark-800/40 border border-dark-700/40 hover:border-dark-600/60 hover:bg-dark-800/60 transition-all">
{/* Header row - name, badges, favorite */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-dark-100">{displayName}</span> <h3 className="font-semibold text-dark-100 text-base">{displayName}</h3>
{setting.has_override && ( {setting.has_override && (
<span className="px-1.5 py-0.5 text-xs rounded bg-warning-500/20 text-warning-400"> <span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium">
{t('admin.settings.modified')} {t('admin.settings.modified')}
</span> </span>
)} )}
{setting.read_only && (
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600/50 text-dark-400 font-medium flex items-center gap-1">
<LockIcon />
{t('admin.settings.readOnly')}
</span>
)}
</div> </div>
{description && ( {description && (
<p className="text-sm text-dark-400 mt-1 line-clamp-2">{description}</p> <p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p>
)} )}
</div> </div>
{/* Favorite button */} {/* Favorite button */}
<button <button
onClick={onToggleFavorite} onClick={onToggleFavorite}
className={`p-1.5 rounded-lg transition-all flex-shrink-0 ${ className={`p-2 rounded-xl transition-all flex-shrink-0 ${
isFavorite isFavorite
? 'text-warning-400 bg-warning-500/10' ? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25'
: 'text-dark-500 hover:text-dark-300 opacity-0 group-hover:opacity-100' : 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100'
}`} }`}
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
> >
<StarIcon filled={isFavorite} /> <StarIcon filled={isFavorite} />
</button> </button>
</div> </div>
{/* Bottom row - control */} {/* Setting key (muted) */}
<div className="flex items-center justify-between gap-3 mt-3 pt-3 border-t border-dark-700/30"> <div className="mb-3">
<span className="text-xs text-dark-500 font-mono truncate max-w-[200px]">{setting.key}</span> <code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded">
{setting.key}
</code>
</div>
<div className="flex items-center gap-2 flex-shrink-0"> {/* Control section */}
{/* Setting control */} <div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}>
{setting.read_only ? ( {setting.read_only ? (
<div className="flex items-center gap-2 text-dark-400"> // Read-only display
<LockIcon /> <div className="flex items-center gap-2 text-dark-300 bg-dark-700/30 rounded-lg px-4 py-2.5">
<span className="font-mono text-sm max-w-[150px] truncate">{String(setting.current ?? '-')}</span> <span className="font-mono text-sm break-all">{String(setting.current ?? '-')}</span>
</div> </div>
) : setting.type === 'bool' ? ( ) : setting.type === 'bool' ? (
// Boolean toggle
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-dark-400">
{setting.current === true || setting.current === 'true' ? 'Включено' : 'Выключено'}
</span>
<div className="flex items-center gap-2">
<Toggle <Toggle
checked={setting.current === true || setting.current === 'true'} checked={setting.current === true || setting.current === 'true'}
onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')} onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')}
disabled={isUpdating} disabled={isUpdating}
/> />
) : ( {/* Reset button for boolean */}
<SettingInput {setting.has_override && (
setting={setting}
onUpdate={onUpdate}
disabled={isUpdating}
/>
)}
{/* Reset button */}
{setting.has_override && !setting.read_only && (
<button <button
onClick={onReset} onClick={onReset}
disabled={isResetting} disabled={isResetting}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors" className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
title={t('admin.settings.reset')} title={t('admin.settings.reset')}
> >
<RefreshIcon /> <RefreshIcon />
@@ -99,6 +124,43 @@ export function SettingRow({
)} )}
</div> </div>
</div> </div>
) : (
// Input field
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}>
<SettingInput
setting={setting}
onUpdate={onUpdate}
disabled={isUpdating}
/>
{/* Reset button for non-long values */}
{!isLongValue && setting.has_override && (
<button
onClick={onReset}
disabled={isResetting}
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
title={t('admin.settings.reset')}
>
<RefreshIcon />
</button>
)}
</div>
)}
</div>
{/* Reset button for long values - shown below */}
{isLongValue && setting.has_override && !setting.read_only && setting.type !== 'bool' && (
<div className="mt-3 flex justify-end">
<button
onClick={onReset}
disabled={isResetting}
className="px-3 py-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 text-sm flex items-center gap-1.5"
title={t('admin.settings.reset')}
>
<RefreshIcon />
<span>Сбросить</span>
</button>
</div>
)}
</div> </div>
) )
} }

View File

@@ -66,7 +66,7 @@ export function SettingsTab({
<p className="text-dark-400">{t('admin.settings.noSettings')}</p> <p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredSettings.map((setting) => ( {filteredSettings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}
@@ -114,7 +114,7 @@ export function SettingsTab({
{/* Accordion content */} {/* Accordion content */}
{isExpanded && ( {isExpanded && (
<div className="p-4 pt-0 border-t border-dark-700/30"> <div className="p-4 pt-0 border-t border-dark-700/30">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 pt-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
{cat.settings.map((setting) => ( {cat.settings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}

View File

@@ -83,3 +83,9 @@ export const MenuIcon = () => (
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg> </svg>
) )
export const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>
)

View File

@@ -1191,7 +1191,7 @@
"POSTGRES": "PostgreSQL", "POSTGRES": "PostgreSQL",
"SQLITE": "SQLite", "SQLITE": "SQLite",
"REDIS": "Redis", "REDIS": "Redis",
"CORE": "Ядро", "CORE": "Бот",
"REMNAWAVE": "RemnaWave", "REMNAWAVE": "RemnaWave",
"SERVER_STATUS": "Статус сервера", "SERVER_STATUS": "Статус сервера",
"MONITORING": "Мониторинг", "MONITORING": "Мониторинг",