mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
Redesign AdminSettings with tabs navigation and favorites
- Add tab-based navigation (Favorites, Branding, Theme, Payments, etc.) - Create useFavoriteSettings hook for localStorage-based favorites - Create SettingCard component for consistent setting display - Add global search functionality - Implement collapsible sections for theme presets and colors - Group settings by meta-categories (payments, subscriptions, etc.) - Add dropdown for additional categories (More menu) - Modern UI with improved spacing and interactions
This commit is contained in:
259
src/components/admin/SettingCard.tsx
Normal file
259
src/components/admin/SettingCard.tsx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface SettingChoice {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingDefinition {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category: string
|
||||||
|
type: string
|
||||||
|
is_optional: boolean
|
||||||
|
current: string | number | boolean | null
|
||||||
|
original: string | number | boolean | null
|
||||||
|
has_override: boolean
|
||||||
|
read_only: boolean
|
||||||
|
choices?: SettingChoice[]
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingCardProps {
|
||||||
|
setting: SettingDefinition
|
||||||
|
translatedName?: string
|
||||||
|
translatedDescription?: string
|
||||||
|
isFavorite: boolean
|
||||||
|
onToggleFavorite: () => void
|
||||||
|
onUpdate: (value: string) => void
|
||||||
|
onReset: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||||
|
<svg className="w-4 h-4" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const LockIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CloseIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export function SettingCard({
|
||||||
|
setting,
|
||||||
|
translatedName,
|
||||||
|
translatedDescription,
|
||||||
|
isFavorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
onUpdate,
|
||||||
|
onReset,
|
||||||
|
disabled
|
||||||
|
}: SettingCardProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
|
const displayName = translatedName || setting.name || setting.key
|
||||||
|
const displayDescription = translatedDescription || setting.description
|
||||||
|
|
||||||
|
const handleStartEdit = useCallback(() => {
|
||||||
|
if (setting.read_only) return
|
||||||
|
setEditValue(String(setting.current ?? ''))
|
||||||
|
setIsEditing(true)
|
||||||
|
}, [setting.current, setting.read_only])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
onUpdate(editValue)
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [editValue, onUpdate])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
setEditValue('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
}, [handleSave, handleCancel])
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (setting.read_only) return
|
||||||
|
const newValue = setting.current === true || setting.current === 'true' ? 'false' : 'true'
|
||||||
|
onUpdate(newValue)
|
||||||
|
}, [setting.current, setting.read_only, onUpdate])
|
||||||
|
|
||||||
|
const handleSelectChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
onUpdate(e.target.value)
|
||||||
|
}, [onUpdate])
|
||||||
|
|
||||||
|
// Render input based on type
|
||||||
|
const renderInput = () => {
|
||||||
|
// Read-only display
|
||||||
|
if (setting.read_only) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-dark-400">
|
||||||
|
<LockIcon />
|
||||||
|
<span className="font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boolean toggle
|
||||||
|
if (setting.type === 'bool') {
|
||||||
|
const isChecked = setting.current === true || setting.current === 'true'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
|
isChecked ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||||
|
isChecked ? 'left-7' : 'left-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select dropdown
|
||||||
|
if (setting.choices && setting.choices.length > 0) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={String(setting.current ?? '')}
|
||||||
|
onChange={handleSelectChange}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{setting.choices.map(choice => (
|
||||||
|
<option key={choice.value} value={choice.value}>
|
||||||
|
{choice.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text/number input
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type={setting.type === 'int' || setting.type === 'float' ? 'number' : 'text'}
|
||||||
|
value={editValue}
|
||||||
|
onChange={e => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
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-40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="p-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="p-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleStartEdit}
|
||||||
|
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 hover:text-dark-100 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono"
|
||||||
|
>
|
||||||
|
{String(setting.current ?? '-')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`group p-4 rounded-xl bg-dark-800/50 border border-dark-700/50 hover:border-dark-600 transition-all ${
|
||||||
|
setting.has_override ? 'ring-1 ring-warning-500/30' : ''
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* Left side - info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-dark-100 truncate">{displayName}</span>
|
||||||
|
{setting.has_override && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs rounded bg-warning-500/20 text-warning-400">
|
||||||
|
Изменено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{displayDescription && (
|
||||||
|
<p className="text-sm text-dark-400 line-clamp-2">{displayDescription}</p>
|
||||||
|
)}
|
||||||
|
{setting.hint && (
|
||||||
|
<p className="text-xs text-warning-400 mt-1">{setting.hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - controls */}
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Favorite button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleFavorite}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
isFavorite
|
||||||
|
? 'text-warning-400 bg-warning-500/10'
|
||||||
|
: 'text-dark-500 hover:text-dark-300 opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
title={isFavorite ? 'Убрать из избранного' : 'Добавить в избранное'}
|
||||||
|
>
|
||||||
|
<StarIcon filled={isFavorite} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Input control */}
|
||||||
|
{renderInput()}
|
||||||
|
|
||||||
|
{/* Reset button */}
|
||||||
|
{setting.has_override && !setting.read_only && (
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={disabled}
|
||||||
|
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
|
||||||
|
title="Сбросить к значению по умолчанию"
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/hooks/useFavoriteSettings.ts
Normal file
49
src/hooks/useFavoriteSettings.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'admin_favorite_settings'
|
||||||
|
|
||||||
|
export function useFavoriteSettings() {
|
||||||
|
const [favorites, setFavorites] = useState<string[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
return stored ? JSON.parse(stored) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}, [favorites])
|
||||||
|
|
||||||
|
const toggleFavorite = useCallback((settingKey: string) => {
|
||||||
|
setFavorites(prev => {
|
||||||
|
if (prev.includes(settingKey)) {
|
||||||
|
return prev.filter(key => key !== settingKey)
|
||||||
|
} else {
|
||||||
|
return [...prev, settingKey]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isFavorite = useCallback((settingKey: string) => {
|
||||||
|
return favorites.includes(settingKey)
|
||||||
|
}, [favorites])
|
||||||
|
|
||||||
|
const clearFavorites = useCallback(() => {
|
||||||
|
setFavorites([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
favorites,
|
||||||
|
toggleFavorite,
|
||||||
|
isFavorite,
|
||||||
|
clearFavorites,
|
||||||
|
count: favorites.length
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user