feat: settings page with sidebar categories + secret fields toggle

This commit is contained in:
Nik (Claude)
2026-06-16 09:36:17 +03:00
parent ef5d5f45ff
commit 036ee28025
+217 -137
View File
@@ -1,155 +1,235 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Check, Eye, EyeOff, Loader2, Save, AlertCircle } from 'lucide-react'; import { Save, Eye, EyeOff } from 'lucide-react';
const CATEGORY_LABELS = { // ── Категории и человекочитаемые названия ──────────────────────────────────
telegram: { title: 'Telegram', hint: 'Прокси к Bot API. Нужен на серверах в РФ.' }, const CATEGORIES = [
engine: { title: 'Engine', hint: 'Системные настройки engine (Telegram-прокси и т.п.).' }, {
general: { title: 'Общие', hint: '' }, id: 'general',
label: 'Основные',
keys: ['APP_PUBLIC_URL', 'ENGINE_PUBLIC_URL', 'MAINTENANCE_MODE', 'MAINTENANCE_MESSAGE'],
},
{
id: 'ai-text',
label: 'AI — Текст',
keys: ['AI_TEXT_BASE_URL', 'AI_TEXT_API_KEY', 'AI_TEXT_MODEL_ARTICLE', 'AI_TEXT_MODEL_POST', 'AI_TEXT_MODEL_TOPICS', 'AI_USD_RUB_RATE', 'AI_PROVIDER_MARKUP'],
},
{
id: 'ai-image',
label: 'AI — Картинки',
keys: ['ROUTERAI_BASE_URL', 'ROUTERAI_API_KEY', 'ROUTERAI_IMAGE_MODEL', 'AI_IMAGE_BASE_URL', 'AI_IMAGE_API_KEY', 'AI_IMAGE_MODEL', 'AI_IMAGE_MODEL_VIA_RESPONSES', 'AI_IMAGE_FALLBACK_BASE_URL', 'AI_IMAGE_FALLBACK_API_KEY'],
},
{
id: 'telegram',
label: 'Telegram',
keys: ['TELEGRAM_API_BASE'],
},
{
id: 'photo-search',
label: 'Поиск фото',
keys: ['PHOTO_SEARCH_PROVIDER', 'YANDEX_SEARCH_API_KEY', 'YANDEX_SEARCH_FOLDER_ID', 'YANDEX_SEARCH_DAILY_LIMIT'],
},
{
id: 'defaults',
label: 'Каналы (умолчания)',
keys: ['DEFAULT_POST_LANGUAGE', 'DEFAULT_POST_LENGTH', 'DEFAULT_POST_STYLE', 'DEFAULT_POST_GOAL', 'DEFAULT_IMAGE_ENABLED', 'DEFAULT_EMOJI_ENABLED', 'DEFAULT_HASHTAGS_IN_POST', 'DEFAULT_AI_STYLE_PROMPT', 'DEFAULT_AUTO_DRAFT_COUNT', 'DEFAULT_AUTO_DRAFT_TIME', 'AUTO_DRAFT_DEFAULT_COUNT', 'AUTO_DRAFT_DEFAULT_TIME'],
},
{
id: 'smtp',
label: 'Email (SMTP)',
keys: ['SMTP_ENABLED', 'SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS', 'SMTP_FROM'],
},
{
id: 'payments',
label: 'ЮKassa',
keys: ['YUKASSA_SHOP_ID', 'YUKASSA_SECRET', 'YUKASSA_RETURN_URL'],
},
];
const KEY_LABELS = {
APP_PUBLIC_URL: 'URL приложения',
ENGINE_PUBLIC_URL: 'URL движка',
MAINTENANCE_MODE: 'Режим обслуживания',
MAINTENANCE_MESSAGE: 'Сообщение при обслуживании',
AI_TEXT_BASE_URL: 'Base URL провайдера',
AI_TEXT_API_KEY: 'API ключ',
AI_TEXT_MODEL_ARTICLE: 'Модель для статей',
AI_TEXT_MODEL_POST: 'Модель для постов',
AI_TEXT_MODEL_TOPICS: 'Модель для тем',
AI_USD_RUB_RATE: 'Курс USD → RUB',
AI_PROVIDER_MARKUP: 'Коэффициент наценки',
ROUTERAI_BASE_URL: 'RouterAI Base URL',
ROUTERAI_API_KEY: 'RouterAI API ключ',
ROUTERAI_IMAGE_MODEL: 'Модель картинок',
AI_IMAGE_BASE_URL: 'Fallback Base URL',
AI_IMAGE_API_KEY: 'Fallback API ключ',
AI_IMAGE_MODEL: 'Fallback модель',
AI_IMAGE_MODEL_VIA_RESPONSES: 'Fallback модель (responses)',
AI_IMAGE_FALLBACK_BASE_URL: 'Fallback Base URL 2',
AI_IMAGE_FALLBACK_API_KEY: 'Fallback API ключ 2',
TELEGRAM_API_BASE: 'Telegram Bot API Base URL',
PHOTO_SEARCH_PROVIDER: 'Провайдер поиска фото',
YANDEX_SEARCH_API_KEY: 'Yandex API ключ',
YANDEX_SEARCH_FOLDER_ID: 'Yandex Folder ID',
YANDEX_SEARCH_DAILY_LIMIT: 'Дневной лимит запросов',
DEFAULT_POST_LANGUAGE: 'Язык постов',
DEFAULT_POST_LENGTH: 'Длина поста',
DEFAULT_POST_STYLE: 'Стиль',
DEFAULT_POST_GOAL: 'Цель поста',
DEFAULT_IMAGE_ENABLED: 'Генерировать картинку',
DEFAULT_EMOJI_ENABLED: 'Использовать эмодзи',
DEFAULT_HASHTAGS_IN_POST: 'Добавлять хештеги',
DEFAULT_AI_STYLE_PROMPT: 'Базовый стиль (промпт)',
DEFAULT_AUTO_DRAFT_COUNT: 'Черновиков в день',
DEFAULT_AUTO_DRAFT_TIME: 'Время генерации черновиков',
AUTO_DRAFT_DEFAULT_COUNT: 'Черновиков по умолчанию',
AUTO_DRAFT_DEFAULT_TIME: 'Время по умолчанию',
SMTP_ENABLED: 'Email включён',
SMTP_HOST: 'SMTP сервер',
SMTP_PORT: 'SMTP порт',
SMTP_USER: 'SMTP логин',
SMTP_PASS: 'SMTP пароль',
SMTP_FROM: 'Email отправителя',
YUKASSA_SHOP_ID: 'ID магазина',
YUKASSA_SECRET: 'Секретный ключ',
YUKASSA_RETURN_URL: 'URL возврата',
}; };
// Категории, которые здесь НЕ показываются: они управляются из app.zeropost.ru/system. const SECRET_KEYS = new Set([
const HIDDEN_CATEGORIES = new Set([ 'AI_TEXT_API_KEY', 'AI_IMAGE_API_KEY', 'AI_IMAGE_FALLBACK_API_KEY',
'photo_search', 'ROUTERAI_API_KEY', 'YUKASSA_SECRET', 'SMTP_PASS', 'YANDEX_SEARCH_API_KEY',
]); ]);
export default function SettingsForm({ initial }) { function SettingRow({ setting, engineUrl }) {
const filtered = (initial || []).filter(s => !HIDDEN_CATEGORIES.has(s.category)); const [val, setVal] = useState(setting.value || '');
// Группируем настройки по категориям const [saving, setSaving] = useState(false);
const byCategory = filtered.reduce((acc, s) => { const [saved, setSaved] = useState(false);
(acc[s.category] ||= []).push(s); const [error, setError] = useState('');
return acc; const [showSecret, setShowSecret] = useState(false);
}, {}); const isSecret = SECRET_KEYS.has(setting.key);
const order = ['engine', 'telegram', 'general']; const label = KEY_LABELS[setting.key] || setting.key;
const categories = [...order, ...Object.keys(byCategory).filter(c => !order.includes(c))];
return (
<div className="space-y-8">
{categories.filter(c => byCategory[c]).map(cat => {
const info = CATEGORY_LABELS[cat] || { title: cat, hint: '' };
return (
<section key={cat} className="space-y-3">
<div>
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{info.title}</h2>
{info.hint && <p className="text-xs text-neutral-500 mt-0.5">{info.hint}</p>}
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-800">
{byCategory[cat].map(s => <SettingRow key={s.key} setting={s} />)}
</div>
</section>
);
})}
<p className="text-xs text-neutral-500">
Системные настройки внешних сервисов (поиск фото и т.п.) управляются из{' '}
<a href="https://app.zeropost.ru/system" className="text-emerald-600 dark:text-emerald-400 hover:underline">
app.zeropost.ru/system
</a>.
</p>
</div>
);
}
function SettingRow({ setting }) {
const [value, setValue] = useState(setting.value ?? '');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState(null);
const [reveal, setReveal] = useState(false);
const original = setting.value ?? '';
const dirty = value !== original;
async function save() { async function save() {
setSaving(true); setSaving(true); setSaved(false); setError('');
setError(null);
setSaved(false);
try { try {
const r = await fetch(`/admin/api/settings/${encodeURIComponent(setting.key)}`, { const r = await fetch(`${engineUrl}/api/settings/${setting.key}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '' },
body: JSON.stringify({ value }), body: JSON.stringify({ value: val }),
}); });
if (!r.ok) { const d = await r.json();
const d = await r.json().catch(() => ({})); if (d.ok || d.key) { setSaved(true); setTimeout(() => setSaved(false), 2000); }
throw new Error(d.error || `HTTP ${r.status}`); else setError(d.error || 'Ошибка');
} } catch { setError('Сеть'); }
setSaved(true); setSaving(false);
setTimeout(() => setSaved(false), 2000);
// Обновляем "оригинал" чтобы dirty стал false
setting.value = value;
} catch (e) {
setError(e.message);
} finally {
setSaving(false);
}
} }
const isSecret = setting.is_secret;
const inputType = isSecret && !reveal ? 'password' : 'text';
const isLong = (value?.length ?? 0) > 80;
return ( return (
<div className="p-4 sm:p-5"> <div className="py-4 border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<div className="flex items-baseline justify-between gap-3 mb-1"> <div className="flex items-start justify-between gap-3">
<code className="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 break-all"> <div className="flex-1 min-w-0">
{setting.key} <div className="text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-0.5">{label}</div>
</code> {setting.description && (
{isSecret && ( <div className="text-xs text-neutral-400 mb-2 leading-relaxed">{setting.description}</div>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-400 font-semibold shrink-0"> )}
secret <div className="flex items-center gap-2">
</span> <div className="relative flex-1">
)} <input
</div> type={isSecret && !showSecret ? 'password' : 'text'}
{setting.description && ( value={val}
<p className="text-xs text-neutral-500 mb-3">{setting.description}</p> onChange={e => setVal(e.target.value)}
)} onKeyDown={e => e.key === 'Enter' && save()}
<div className="flex gap-2 items-stretch"> className="w-full text-sm border border-neutral-200 dark:border-neutral-700 rounded-lg px-3 py-2 bg-white dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-400"
{isLong ? ( />
<textarea {isSecret && (
value={value} <button
onChange={e => setValue(e.target.value)} type="button"
rows={2} onClick={() => setShowSecret(v => !v)}
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600"
/> >
) : ( {showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
<input </button>
type={inputType} )}
value={value} </div>
onChange={e => setValue(e.target.value)} <button
placeholder={setting.value === null ? '(не задано)' : ''} onClick={save}
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500" disabled={saving}
/> className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition ${
)} saved
{isSecret && ( ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
<button : 'bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 hover:opacity-80'
type="button" } disabled:opacity-50`}
onClick={() => setReveal(r => !r)} >
title={reveal ? 'Скрыть' : 'Показать'} <Save className="w-3.5 h-3.5" />
className="px-3 rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors" {saving ? 'Сохраняю...' : saved ? 'Сохранено' : 'Сохранить'}
> </button>
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} </div>
</button> {error && <div className="text-xs text-red-500 mt-1">{error}</div>}
)}
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className={`px-4 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
dirty
? 'bg-emerald-500 hover:bg-emerald-600 text-white'
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 cursor-not-allowed'
}`}
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : saved ? <Check className="w-4 h-4" /> : <Save className="w-4 h-4" />}
{saved ? 'Сохранено' : 'Сохранить'}
</button>
</div>
{error && (
<div className="mt-2 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
<span>{error}</span>
</div> </div>
)} </div>
</div>
);
}
export default function SettingsForm({ initial }) {
const [activeCategory, setActiveCategory] = useState('general');
const engineUrl = process.env.NEXT_PUBLIC_ENGINE_URL || '';
// Строим map key→setting
const settingsMap = useMemo(() => {
const map = {};
(initial || []).forEach(s => { map[s.key] = s; });
return map;
}, [initial]);
// Незнакомые ключи — добавляем в категорию "Прочее"
const knownKeys = new Set(CATEGORIES.flatMap(c => c.keys));
const unknownSettings = (initial || []).filter(s => !knownKeys.has(s.key));
const allCategories = unknownSettings.length > 0
? [...CATEGORIES, { id: 'other', label: 'Прочее', keys: unknownSettings.map(s => s.key) }]
: CATEGORIES;
const activeKeys = allCategories.find(c => c.id === activeCategory)?.keys || [];
const activeSettings = activeKeys
.map(key => settingsMap[key] || { key, value: '', description: '' })
.filter(Boolean);
return (
<div className="flex gap-6">
{/* Боковое меню */}
<aside className="w-48 shrink-0">
<nav className="space-y-0.5 sticky top-4">
{allCategories.map(cat => {
const count = cat.keys.filter(k => settingsMap[k]).length;
return (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center justify-between ${
activeCategory === cat.id
? 'bg-emerald-50 dark:bg-emerald-950/50 text-emerald-700 dark:text-emerald-300 font-medium'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`}
>
<span>{cat.label}</span>
<span className="text-xs text-neutral-400">{count}</span>
</button>
);
})}
</nav>
</aside>
{/* Контент */}
<div className="flex-1 min-w-0 bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 px-6 py-2">
{activeSettings.length === 0 ? (
<div className="py-12 text-center text-sm text-neutral-400">Нет настроек в этой категории</div>
) : (
activeSettings.map(s => (
<SettingRow key={s.key} setting={s} engineUrl={engineUrl} />
))
)}
</div>
</div> </div>
); );
} }