feat: settings page with sidebar categories + secret fields toggle
This commit is contained in:
@@ -1,155 +1,235 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Check, Eye, EyeOff, Loader2, Save, AlertCircle } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Save, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
telegram: { title: 'Telegram', hint: 'Прокси к Bot API. Нужен на серверах в РФ.' },
|
||||
engine: { title: 'Engine', hint: 'Системные настройки engine (Telegram-прокси и т.п.).' },
|
||||
general: { title: 'Общие', hint: '' },
|
||||
// ── Категории и человекочитаемые названия ──────────────────────────────────
|
||||
const CATEGORIES = [
|
||||
{
|
||||
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 HIDDEN_CATEGORIES = new Set([
|
||||
'photo_search',
|
||||
const SECRET_KEYS = new Set([
|
||||
'AI_TEXT_API_KEY', 'AI_IMAGE_API_KEY', 'AI_IMAGE_FALLBACK_API_KEY',
|
||||
'ROUTERAI_API_KEY', 'YUKASSA_SECRET', 'SMTP_PASS', 'YANDEX_SEARCH_API_KEY',
|
||||
]);
|
||||
|
||||
export default function SettingsForm({ initial }) {
|
||||
const filtered = (initial || []).filter(s => !HIDDEN_CATEGORIES.has(s.category));
|
||||
// Группируем настройки по категориям
|
||||
const byCategory = filtered.reduce((acc, s) => {
|
||||
(acc[s.category] ||= []).push(s);
|
||||
return acc;
|
||||
}, {});
|
||||
const order = ['engine', 'telegram', 'general'];
|
||||
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;
|
||||
function SettingRow({ setting, engineUrl }) {
|
||||
const [val, setVal] = useState(setting.value || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const isSecret = SECRET_KEYS.has(setting.key);
|
||||
const label = KEY_LABELS[setting.key] || setting.key;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
setSaving(true); setSaved(false); setError('');
|
||||
try {
|
||||
const r = await fetch(`/admin/api/settings/${encodeURIComponent(setting.key)}`, {
|
||||
const r = await fetch(`${engineUrl}/api/settings/${setting.key}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '' },
|
||||
body: JSON.stringify({ value: val }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
throw new Error(d.error || `HTTP ${r.status}`);
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
// Обновляем "оригинал" чтобы dirty стал false
|
||||
setting.value = value;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const d = await r.json();
|
||||
if (d.ok || d.key) { setSaved(true); setTimeout(() => setSaved(false), 2000); }
|
||||
else setError(d.error || 'Ошибка');
|
||||
} catch { setError('Сеть'); }
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const isSecret = setting.is_secret;
|
||||
const inputType = isSecret && !reveal ? 'password' : 'text';
|
||||
const isLong = (value?.length ?? 0) > 80;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-1">
|
||||
<code className="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 break-all">
|
||||
{setting.key}
|
||||
</code>
|
||||
{isSecret && (
|
||||
<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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{setting.description && (
|
||||
<p className="text-xs text-neutral-500 mb-3">{setting.description}</p>
|
||||
)}
|
||||
<div className="flex gap-2 items-stretch">
|
||||
{isLong ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
rows={2}
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={setting.value === null ? '(не задано)' : ''}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReveal(r => !r)}
|
||||
title={reveal ? 'Скрыть' : 'Показать'}
|
||||
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"
|
||||
>
|
||||
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
<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 className="py-4 border-b border-neutral-100 dark:border-neutral-800 last:border-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-0.5">{label}</div>
|
||||
{setting.description && (
|
||||
<div className="text-xs text-neutral-400 mb-2 leading-relaxed">{setting.description}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={isSecret && !showSecret ? 'password' : 'text'}
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && save()}
|
||||
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"
|
||||
/>
|
||||
{isSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(v => !v)}
|
||||
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" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition ${
|
||||
saved
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
|
||||
: 'bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 hover:opacity-80'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{saving ? 'Сохраняю...' : saved ? 'Сохранено' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-xs text-red-500 mt-1">{error}</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user