mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
refactor: migrate to eslint flat config and format codebase with prettier
- Remove legacy .eslintrc.cjs and .eslintignore - Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url) - Add .prettierrc and .prettierignore - Format entire codebase with prettier
This commit is contained in:
@@ -1,97 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../../api/branding'
|
||||
import { CheckIcon, CloseIcon } from './icons'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../../api/branding';
|
||||
import { CheckIcon, CloseIcon } from './icons';
|
||||
|
||||
export function AnalyticsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Editing states
|
||||
const [editingYandex, setEditingYandex] = useState(false)
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false)
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
|
||||
const [yandexValue, setYandexValue] = useState('')
|
||||
const [googleIdValue, setGoogleIdValue] = useState('')
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingYandex, setEditingYandex] = useState(false);
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false);
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
|
||||
const [yandexValue, setYandexValue] = useState('');
|
||||
const [googleIdValue, setGoogleIdValue] = useState('');
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Query
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ['analytics-counters'],
|
||||
queryFn: brandingApi.getAnalyticsCounters,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: brandingApi.updateAnalyticsCounters,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
|
||||
setError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
setError(detail || t('common.error'))
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
setError(detail || t('common.error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSaveYandex = () => {
|
||||
updateMutation.mutate(
|
||||
{ yandex_metrika_id: yandexValue.trim() },
|
||||
{ onSuccess: () => setEditingYandex(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleId = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_id: googleIdValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleId(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleLabel = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_label: googleLabelValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleLabel(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id)
|
||||
const googleActive = Boolean(analytics?.google_ads_id)
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id);
|
||||
const googleActive = Boolean(analytics?.google_ads_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
|
||||
<div className="rounded-2xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yandex Metrika */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.yandexMetrika')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
yandexActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">
|
||||
{t('admin.settings.yandexMetrikaDesc')}
|
||||
</p>
|
||||
|
||||
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
|
||||
value={yandexValue}
|
||||
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder={t('admin.settings.yandexIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveYandex}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingYandex(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingYandex(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.yandex_metrika_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setYandexValue(analytics?.yandex_metrika_id || '')
|
||||
setEditingYandex(true)
|
||||
setError(null)
|
||||
setYandexValue(analytics?.yandex_metrika_id || '');
|
||||
setEditingYandex(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.yandexIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Ads */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.googleAds')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
googleActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
{t('admin.settings.googleAdsDesc')}
|
||||
</p>
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Conversion ID */}
|
||||
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
|
||||
value={googleIdValue}
|
||||
onChange={(e) => setGoogleIdValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleId}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleId(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleId(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleIdValue(analytics?.google_ads_id || '')
|
||||
setEditingGoogleId(true)
|
||||
setError(null)
|
||||
setGoogleIdValue(analytics?.google_ads_id || '');
|
||||
setEditingGoogleId(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Conversion Label */}
|
||||
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
|
||||
value={googleLabelValue}
|
||||
onChange={(e) => setGoogleLabelValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleLabelPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleLabel}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleLabel(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '')
|
||||
setEditingGoogleLabel(true)
|
||||
setError(null)
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '');
|
||||
setEditingGoogleLabel(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleLabelHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info block */}
|
||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
||||
<p className="text-sm text-dark-500 leading-relaxed">
|
||||
{t('admin.settings.analyticsHint')}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
|
||||
<p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,130 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding'
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground'
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface BrandingTabProps {
|
||||
accentColor?: string
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: fullscreenSettings } = useQuery({
|
||||
queryKey: ['fullscreen-enabled'],
|
||||
queryFn: brandingApi.getFullscreenEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: emailAuthSettings } = useQuery({
|
||||
queryKey: ['email-auth-enabled'],
|
||||
queryFn: brandingApi.getEmailAuthEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateBrandingMutation = useMutation({
|
||||
mutationFn: brandingApi.updateName,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setEditingName(false)
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
setEditingName(false);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.uploadLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.deleteLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateAnimationMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedAnimationEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
|
||||
setCachedAnimationEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateFullscreenMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedFullscreenEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
|
||||
setCachedFullscreenEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEmailAuthMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadLogoMutation.mutate(file)
|
||||
uploadLogoMutation.mutate(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Logo & Name */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.logoAndName')}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-3xl font-bold text-white overflow-hidden"
|
||||
className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl text-3xl font-bold text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
|
||||
}}
|
||||
>
|
||||
{branding?.has_custom_logo ? (
|
||||
<img src={brandingApi.getLogoUrl(branding) ?? undefined} alt="Logo" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={brandingApi.getLogoUrl(branding) ?? undefined}
|
||||
alt="Logo"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
branding?.logo_letter || 'V'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50"
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<UploadIcon />
|
||||
</button>
|
||||
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
className="px-3 py-2 rounded-xl bg-dark-700 hover:bg-error-500/20 text-dark-400 hover:text-error-400 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-3 py-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">{t('admin.settings.projectName')}</label>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.projectName')}
|
||||
</label>
|
||||
{editingName ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
maxLength={50}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateBrandingMutation.mutate(newName)}
|
||||
disabled={updateBrandingMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingName(false)}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg text-dark-100">{branding?.name || t('admin.settings.notSpecified')}</span>
|
||||
<span className="text-lg text-dark-100">
|
||||
{branding?.name || t('admin.settings.notSpecified')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewName(branding?.name ?? '')
|
||||
setEditingName(true)
|
||||
setNewName(branding?.name ?? '');
|
||||
setEditingName(true);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Animation & Fullscreen toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.interfaceOptions')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.animatedBackground')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.animatedBackground')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.autoFullscreen')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.autoFullscreen')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={fullscreenSettings?.enabled ?? false}
|
||||
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
|
||||
onChange={() =>
|
||||
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
|
||||
}
|
||||
disabled={updateFullscreenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
||||
@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { StarIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { StarIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface FavoritesTabProps {
|
||||
settings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
settings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (settings.length === 0) {
|
||||
return (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="flex justify-center mb-4 text-dark-500">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<div className="mb-4 flex justify-center text-dark-500">
|
||||
<StarIcon filled={false} />
|
||||
</div>
|
||||
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
|
||||
<p className="text-dark-500 text-sm mt-1">{t('admin.settings.favoritesHint')}</p>
|
||||
<p className="mt-1 text-sm text-dark-500">{t('admin.settings.favoritesHint')}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -55,5 +56,5 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons';
|
||||
|
||||
interface SettingInputProps {
|
||||
setting: SettingDefinition
|
||||
onUpdate: (value: string) => void
|
||||
disabled?: boolean
|
||||
setting: SettingDefinition;
|
||||
onUpdate: (value: string) => void;
|
||||
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('{')
|
||||
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()
|
||||
const lowerKey = key.toLowerCase();
|
||||
return (
|
||||
lowerKey.includes('_items') ||
|
||||
lowerKey.includes('_config') ||
|
||||
@@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean {
|
||||
lowerKey.includes('_periods') ||
|
||||
lowerKey.includes('_discounts') ||
|
||||
lowerKey.includes('_packages')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
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)
|
||||
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'
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px';
|
||||
}
|
||||
}, [value, isEditing])
|
||||
}, [value, isEditing]);
|
||||
|
||||
const handleStart = () => {
|
||||
setValue(currentValue)
|
||||
setIsEditing(true)
|
||||
}
|
||||
setValue(currentValue);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
onUpdate(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false)
|
||||
setValue('')
|
||||
}
|
||||
setIsEditing(false);
|
||||
setValue('');
|
||||
};
|
||||
|
||||
// Dropdown for choices
|
||||
if (setting.choices && setting.choices.length > 0) {
|
||||
@@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={currentValue}
|
||||
onChange={(e) => onUpdate(e.target.value)}
|
||||
disabled={disabled}
|
||||
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"
|
||||
className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
{setting.choices.map((choice, idx) => (
|
||||
<option key={idx} value={String(choice.value)}>
|
||||
@@ -78,7 +78,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Textarea for long values
|
||||
@@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
// Ctrl+Enter to save
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave()
|
||||
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]"
|
||||
className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
|
||||
/>
|
||||
<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"
|
||||
className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
|
||||
>
|
||||
Отмена
|
||||
</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"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<CheckIcon />
|
||||
Сохранить
|
||||
@@ -117,7 +117,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Regular input
|
||||
@@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
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"
|
||||
className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
|
||||
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||
title="Сохранить (Enter)"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
|
||||
className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
|
||||
title="Отмена (Esc)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Long value preview
|
||||
if (needsTextarea) {
|
||||
const displayValue = currentValue || '-'
|
||||
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue
|
||||
const displayValue = currentValue || '-';
|
||||
const previewValue =
|
||||
displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={disabled}
|
||||
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"
|
||||
className="group w-full rounded-xl border border-dark-600 bg-dark-700/50 px-4 py-3 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<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">
|
||||
<span className="line-clamp-2 flex-1 break-all">{previewValue}</span>
|
||||
<span className="flex-shrink-0 text-dark-500 transition-colors group-hover:text-accent-400">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Short value
|
||||
@@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
<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"
|
||||
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<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">
|
||||
<span className="flex-1 truncate">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons'
|
||||
import { SettingInput } from './SettingInput'
|
||||
import { Toggle } from './Toggle'
|
||||
import { formatSettingKey, stripHtml } from './utils'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons';
|
||||
import { SettingInput } from './SettingInput';
|
||||
import { Toggle } from './Toggle';
|
||||
import { formatSettingKey, stripHtml } from './utils';
|
||||
|
||||
interface SettingRowProps {
|
||||
setting: SettingDefinition
|
||||
isFavorite: boolean
|
||||
onToggleFavorite: () => void
|
||||
onUpdate: (value: string) => void
|
||||
onReset: () => void
|
||||
isUpdating?: boolean
|
||||
isResetting?: boolean
|
||||
setting: SettingDefinition;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
onUpdate: (value: string) => void;
|
||||
onReset: () => void;
|
||||
isUpdating?: boolean;
|
||||
isResetting?: boolean;
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
@@ -22,18 +22,18 @@ export function SettingRow({
|
||||
onUpdate,
|
||||
onReset,
|
||||
isUpdating,
|
||||
isResetting
|
||||
isResetting,
|
||||
}: SettingRowProps) {
|
||||
const { t } = useTranslation()
|
||||
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 formattedKey = formatSettingKey(setting.name || setting.key);
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
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()
|
||||
const val = String(setting.current ?? '');
|
||||
const key = setting.key.toLowerCase();
|
||||
return (
|
||||
val.length > 50 ||
|
||||
val.includes('\n') ||
|
||||
@@ -44,40 +44,40 @@ export function SettingRow({
|
||||
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">
|
||||
<div className="group rounded-2xl border border-dark-700/40 bg-dark-800/40 p-4 transition-all hover:border-dark-600/60 hover:bg-dark-800/60 sm:p-5">
|
||||
{/* 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 items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-dark-100 text-base">{displayName}</h3>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{displayName}</h3>
|
||||
{setting.has_override && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium">
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||
{t('admin.settings.modified')}
|
||||
</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">
|
||||
<span className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400">
|
||||
<LockIcon />
|
||||
{t('admin.settings.readOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className={`p-2 rounded-xl transition-all flex-shrink-0 ${
|
||||
className={`flex-shrink-0 rounded-xl p-2 transition-all ${
|
||||
isFavorite
|
||||
? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100'
|
||||
? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
@@ -87,17 +87,19 @@ export function SettingRow({
|
||||
|
||||
{/* Setting key (muted) */}
|
||||
<div className="mb-3">
|
||||
<code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded">
|
||||
<code className="rounded bg-dark-900/50 px-2 py-1 font-mono text-xs text-dark-500">
|
||||
{setting.key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Control section */}
|
||||
<div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}>
|
||||
<div
|
||||
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
|
||||
>
|
||||
{setting.read_only ? (
|
||||
// Read-only display
|
||||
<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 break-all">{String(setting.current ?? '-')}</span>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||
</div>
|
||||
) : setting.type === 'bool' ? (
|
||||
// Boolean toggle
|
||||
@@ -108,7 +110,11 @@ export function SettingRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
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}
|
||||
/>
|
||||
{/* Reset button for boolean */}
|
||||
@@ -116,7 +122,7 @@ export function SettingRow({
|
||||
<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"
|
||||
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -126,18 +132,16 @@ export function SettingRow({
|
||||
</div>
|
||||
) : (
|
||||
// Input field
|
||||
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}>
|
||||
<SettingInput
|
||||
setting={setting}
|
||||
onUpdate={onUpdate}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div
|
||||
className={`${isLongValue ? 'w-full' : 'flex flex-1 items-center justify-end gap-2'}`}
|
||||
>
|
||||
<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"
|
||||
className="flex-shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -153,7 +157,7 @@ export function SettingRow({
|
||||
<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"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -162,5 +166,5 @@ export function SettingRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchIcon, CloseIcon } from './icons'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SearchIcon, CloseIcon } from './icons';
|
||||
|
||||
interface SettingsSearchProps {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
resultsCount?: number
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
resultsCount?: number;
|
||||
}
|
||||
|
||||
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation()
|
||||
export function SettingsSearchMobile({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 sm:hidden">
|
||||
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) {
|
||||
if (!searchQuery.trim()) return null
|
||||
export function SettingsSearchResults({
|
||||
searchQuery,
|
||||
resultsCount,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
resultsCount: number;
|
||||
}) {
|
||||
if (!searchQuery.trim()) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
||||
</span>
|
||||
{resultsCount > 0 && (
|
||||
<span className="text-dark-500">по запросу «{searchQuery}»</span>
|
||||
)}
|
||||
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection: string
|
||||
setActiveSection: (section: string) => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (open: boolean) => void
|
||||
favoritesCount: number
|
||||
activeSection: string;
|
||||
setActiveSection: (section: string) => void;
|
||||
mobileMenuOpen: boolean;
|
||||
setMobileMenuOpen: (open: boolean) => void;
|
||||
favoritesCount: number;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
@@ -15,27 +15,27 @@ export function SettingsSidebar({
|
||||
setActiveSection,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
favoritesCount
|
||||
favoritesCount,
|
||||
}: SettingsSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside className={`
|
||||
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50
|
||||
w-64 h-screen bg-dark-900 border-r border-dark-700/50 flex-shrink-0
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/admin" className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="ml-auto p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
||||
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -43,39 +43,39 @@ export function SettingsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="p-2 space-y-1 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id
|
||||
const hasIcon = item.iconType === 'star'
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveSection(item.id)
|
||||
setMobileMenuOpen(false)
|
||||
setActiveSection(item.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||
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:text-dark-200 hover:bg-dark-800/50'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||
<span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400">
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { ChevronDownIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface CategoryGroup {
|
||||
key: string
|
||||
label: string
|
||||
settings: SettingDefinition[]
|
||||
key: string;
|
||||
label: string;
|
||||
settings: SettingDefinition[];
|
||||
}
|
||||
|
||||
interface SettingsTabProps {
|
||||
categories: CategoryGroup[]
|
||||
searchQuery: string
|
||||
filteredSettings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
categories: CategoryGroup[];
|
||||
searchQuery: string;
|
||||
filteredSettings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsTab({
|
||||
@@ -24,49 +24,50 @@ export function SettingsTab({
|
||||
searchQuery,
|
||||
filteredSettings,
|
||||
isFavorite,
|
||||
toggleFavorite
|
||||
toggleFavorite,
|
||||
}: SettingsTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key)
|
||||
next.add(key);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// If searching, show flat list
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filteredSettings.length === 0 ? (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredSettings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -82,46 +83,50 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show accordion for subcategories
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((cat) => {
|
||||
const isExpanded = expandedSections.has(cat.key)
|
||||
const isExpanded = expandedSections.has(cat.key);
|
||||
return (
|
||||
<div
|
||||
key={cat.key}
|
||||
className="rounded-2xl bg-dark-800/30 border border-dark-700/30 overflow-hidden"
|
||||
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
|
||||
>
|
||||
{/* Accordion header */}
|
||||
<button
|
||||
onClick={() => toggleSection(cat.key)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-dark-800/50 transition-colors"
|
||||
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="px-2 py-0.5 text-xs rounded-full bg-dark-700 text-dark-400">
|
||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
||||
{cat.settings.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`transition-transform duration-200 text-dark-400 ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
<div
|
||||
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Accordion content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 pt-0 border-t border-dark-700/30">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
|
||||
<div className="border-t border-dark-700/30 p-4 pt-0">
|
||||
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
||||
{cat.settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
isFavorite={isFavorite(setting.key)}
|
||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||
onUpdate={(value) =>
|
||||
updateSettingMutation.mutate({ key: setting.key, value })
|
||||
}
|
||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||
isUpdating={updateSettingMutation.isPending}
|
||||
isResetting={resetSettingMutation.isPending}
|
||||
@@ -131,14 +136,14 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ColorPicker } from '../ColorPicker'
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors'
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme'
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { THEME_PRESETS } from './constants'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { themeColorsApi } from '../../api/themeColors';
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
import { ColorPicker } from '../ColorPicker';
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors';
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme';
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
import { THEME_PRESETS } from './constants';
|
||||
|
||||
export function ThemeTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']))
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']));
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section)
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section)
|
||||
next.add(section);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: themeColors } = useQuery({
|
||||
queryKey: ['theme-colors'],
|
||||
queryFn: themeColorsApi.getColors,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
queryFn: themeColorsApi.getEnabledThemes,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.resetColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEnabledThemesMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateEnabledThemes,
|
||||
onSuccess: (data) => {
|
||||
updateEnabledThemesCache(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] })
|
||||
updateEnabledThemesCache(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.availableThemes')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.availableThemes')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<MoonIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.darkTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.darkTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.dark ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) })
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<SunIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.lightTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.lightTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.light ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) })
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
@@ -104,30 +110,34 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('presets')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.quickPresets')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.quickPresets')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('presets') && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => updateColorsMutation.mutate(preset.colors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="p-3 rounded-xl border border-dark-600 hover:border-dark-500 transition-all hover:scale-[1.02]"
|
||||
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
|
||||
style={{ backgroundColor: preset.colors.darkBackground }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full ring-2 ring-white/20"
|
||||
className="h-4 w-4 rounded-full ring-2 ring-white/20"
|
||||
style={{ backgroundColor: preset.colors.accent }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
|
||||
@@ -135,9 +145,18 @@ export function ThemeTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.success }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.warning }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.error }} />
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.success }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.warning }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.error }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -146,13 +165,17 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('colors')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.customColors')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.customColors')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
@@ -161,7 +184,9 @@ export function ThemeTab() {
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Accent */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.accentColor')}</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.accentColor')}
|
||||
</h4>
|
||||
<ColorPicker
|
||||
label={t('theme.accent')}
|
||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
||||
@@ -172,10 +197,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Dark theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<MoonIcon /> {t('admin.settings.darkTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||
@@ -205,10 +230,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Light theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<SunIcon /> {t('admin.settings.lightTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||
@@ -238,8 +263,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Status colors */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.statusColors')}</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.statusColors')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.success')}
|
||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
||||
@@ -265,7 +292,7 @@ export function ThemeTab() {
|
||||
<button
|
||||
onClick={() => resetColorsMutation.mutate()}
|
||||
disabled={resetColorsMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.settings.resetAllColors')}
|
||||
</button>
|
||||
@@ -273,5 +300,5 @@ export function ThemeTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
disabled?: boolean
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
<button
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-500' : 'bg-dark-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`} />
|
||||
<div
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
|
||||
// Menu item types
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
iconType?: 'star' | null
|
||||
categories?: string[]
|
||||
id: string;
|
||||
iconType?: 'star' | null;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
id: string
|
||||
items: MenuItem[]
|
||||
id: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// Sidebar menu configuration
|
||||
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
|
||||
{ id: 'branding', iconType: null },
|
||||
{ id: 'theme', iconType: null },
|
||||
{ id: 'analytics', iconType: null },
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
items: [
|
||||
{ id: 'payments', iconType: null, categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'] },
|
||||
{ id: 'subscriptions', iconType: null, categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'] },
|
||||
{ id: 'interface', iconType: null, categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'] },
|
||||
{ id: 'notifications', iconType: null, categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'] },
|
||||
{
|
||||
id: 'payments',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'YOOKASSA',
|
||||
'CRYPTOBOT',
|
||||
'HELEKET',
|
||||
'PLATEGA',
|
||||
'TRIBUTE',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
'TELEGRAM',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'SUBSCRIPTIONS_CORE',
|
||||
'SIMPLE_SUBSCRIPTION',
|
||||
'PERIODS',
|
||||
'SUBSCRIPTION_PRICES',
|
||||
'TRAFFIC',
|
||||
'TRAFFIC_PACKAGES',
|
||||
'TRIAL',
|
||||
'AUTOPAY',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'interface',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'INTERFACE',
|
||||
'INTERFACE_BRANDING',
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
iconType: null,
|
||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
||||
},
|
||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
||||
{ id: 'system', iconType: null, categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'] },
|
||||
{ id: 'users', iconType: null, categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'] },
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
id: 'system',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'CORE',
|
||||
'REMNAWAVE',
|
||||
'SERVER_STATUS',
|
||||
'MONITORING',
|
||||
'MAINTENANCE',
|
||||
'BACKUP',
|
||||
'VERSION',
|
||||
'WEB_API',
|
||||
'WEBHOOK',
|
||||
'LOG',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
iconType: null,
|
||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Theme preset type
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
colors: ThemeColors
|
||||
id: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
// Theme presets
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ id: 'standard', colors: DEFAULT_THEME_COLORS },
|
||||
{ id: 'ocean', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'forest', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'sunset', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'violet', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'rose', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'midnight', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'turquoise', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
]
|
||||
{
|
||||
id: 'ocean',
|
||||
colors: {
|
||||
accent: '#0ea5e9',
|
||||
darkBackground: '#0c1222',
|
||||
darkSurface: '#1e293b',
|
||||
darkText: '#f1f5f9',
|
||||
darkTextSecondary: '#94a3b8',
|
||||
lightBackground: '#e0f2fe',
|
||||
lightSurface: '#f0f9ff',
|
||||
lightText: '#0c4a6e',
|
||||
lightTextSecondary: '#0369a1',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
colors: {
|
||||
accent: '#22c55e',
|
||||
darkBackground: '#0a1a0f',
|
||||
darkSurface: '#14532d',
|
||||
darkText: '#f0fdf4',
|
||||
darkTextSecondary: '#86efac',
|
||||
lightBackground: '#dcfce7',
|
||||
lightSurface: '#f0fdf4',
|
||||
lightText: '#14532d',
|
||||
lightTextSecondary: '#166534',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
colors: {
|
||||
accent: '#f97316',
|
||||
darkBackground: '#1c1009',
|
||||
darkSurface: '#2d1a0e',
|
||||
darkText: '#fff7ed',
|
||||
darkTextSecondary: '#fdba74',
|
||||
lightBackground: '#ffedd5',
|
||||
lightSurface: '#fff7ed',
|
||||
lightText: '#7c2d12',
|
||||
lightTextSecondary: '#c2410c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'violet',
|
||||
colors: {
|
||||
accent: '#a855f7',
|
||||
darkBackground: '#0f0a1a',
|
||||
darkSurface: '#1e1b2e',
|
||||
darkText: '#faf5ff',
|
||||
darkTextSecondary: '#c4b5fd',
|
||||
lightBackground: '#f3e8ff',
|
||||
lightSurface: '#faf5ff',
|
||||
lightText: '#581c87',
|
||||
lightTextSecondary: '#7e22ce',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rose',
|
||||
colors: {
|
||||
accent: '#f43f5e',
|
||||
darkBackground: '#1a0a10',
|
||||
darkSurface: '#2d1520',
|
||||
darkText: '#fff1f2',
|
||||
darkTextSecondary: '#fda4af',
|
||||
lightBackground: '#ffe4e6',
|
||||
lightSurface: '#fff1f2',
|
||||
lightText: '#881337',
|
||||
lightTextSecondary: '#be123c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
colors: {
|
||||
accent: '#6366f1',
|
||||
darkBackground: '#030712',
|
||||
darkSurface: '#111827',
|
||||
darkText: '#f9fafb',
|
||||
darkTextSecondary: '#9ca3af',
|
||||
lightBackground: '#e5e7eb',
|
||||
lightSurface: '#f3f4f6',
|
||||
lightText: '#111827',
|
||||
lightTextSecondary: '#4b5563',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turquoise',
|
||||
colors: {
|
||||
accent: '#14b8a6',
|
||||
darkBackground: '#0a1614',
|
||||
darkSurface: '#134e4a',
|
||||
darkText: '#f0fdfa',
|
||||
darkTextSecondary: '#5eead4',
|
||||
lightBackground: '#ccfbf1',
|
||||
lightSurface: '#f0fdfa',
|
||||
lightText: '#134e4a',
|
||||
lightTextSecondary: '#0f766e',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,91 +1,141 @@
|
||||
// Admin Settings Icons
|
||||
|
||||
export const BackIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
||||
<svg className="w-5 h-5" 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
|
||||
className="h-5 w-5"
|
||||
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>
|
||||
)
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const UploadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const TrashIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const PencilIcon = () => (
|
||||
<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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg className="h-4 w-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.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export 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 className="h-4 w-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>
|
||||
)
|
||||
);
|
||||
|
||||
export 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 className="h-4 w-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>
|
||||
)
|
||||
);
|
||||
|
||||
export const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-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>
|
||||
)
|
||||
);
|
||||
|
||||
export const CloseIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-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 const SunIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MoonIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MenuIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</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 className="h-4 w-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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Components
|
||||
export * from './icons'
|
||||
export * from './Toggle'
|
||||
export * from './SettingInput'
|
||||
export * from './SettingRow'
|
||||
export * from './AnalyticsTab'
|
||||
export * from './BrandingTab'
|
||||
export * from './ThemeTab'
|
||||
export * from './FavoritesTab'
|
||||
export * from './SettingsTab'
|
||||
export * from './SettingsSidebar'
|
||||
export * from './SettingsSearch'
|
||||
export * from './icons';
|
||||
export * from './Toggle';
|
||||
export * from './SettingInput';
|
||||
export * from './SettingRow';
|
||||
export * from './AnalyticsTab';
|
||||
export * from './BrandingTab';
|
||||
export * from './ThemeTab';
|
||||
export * from './FavoritesTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './SettingsSidebar';
|
||||
export * from './SettingsSearch';
|
||||
|
||||
// Constants and utils
|
||||
export * from './constants'
|
||||
export * from './utils'
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
// Format setting key from Snake_Case / CamelCase to readable text
|
||||
export function formatSettingKey(name: string): string {
|
||||
if (!name) return ''
|
||||
if (!name) return '';
|
||||
|
||||
return name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, c => c.toUpperCase())
|
||||
return (
|
||||
name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Strip HTML tags and template descriptions from setting descriptions
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
if (!html) return '';
|
||||
const cleaned = html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.trim()
|
||||
.trim();
|
||||
|
||||
// Remove template descriptions like "Параметр X управляет категорией Y"
|
||||
if (cleaned.match(/^Параметр .+ управляет категорией/)) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user