feat: AdminContent — content defaults UI

AdminContent.js: настройки дефолтов по 4 группам
  Контент: язык, стиль, цель, длина (select)
  Форматирование: изображения, эмодзи, хештеги (toggle)
  Авто-черновики: кол-во в день, время генерации
  AI-инструкции: базовый промт (textarea)
Инлайн сохранение — кнопка Сохранить появляется только при изменении
Подсказка: изменения применяются только к новым каналам
AdminPanel: раздел Контент-дефолты с Sliders иконкой
This commit is contained in:
Ник (Claude)
2026-06-13 11:22:56 +03:00
parent 06340ab24e
commit 2e9f099b95
2 changed files with 306 additions and 1 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen } from 'lucide-react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
@@ -8,6 +8,7 @@ import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue';
import AdminLogs from './admin/AdminLogs';
import AdminAutogen from './admin/AdminAutogen';
import AdminContent from './admin/AdminContent';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
@@ -21,6 +22,7 @@ const SECTIONS = [
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
@@ -74,6 +76,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'queue' && <AdminQueue />}
{section === 'logs' && <AdminLogs />}
{section === 'autogen' && <AdminAutogen />}
{section === 'content' && <AdminContent />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />}
+302
View File
@@ -0,0 +1,302 @@
'use client';
import { useState, useEffect } from 'react';
import { Save, Loader2, Check, RefreshCw, Info } from 'lucide-react';
// Метаданные полей для красивого UI
const FIELD_META = {
DEFAULT_POST_LANGUAGE: {
label: 'Язык постов',
desc: 'Применяется к новым каналам',
type: 'select',
options: [
{ v: 'ru', l: '🇷🇺 Русский' },
{ v: 'en', l: '🇬🇧 English' },
{ v: 'auto', l: '🌐 Авто (по нише)' },
],
},
DEFAULT_POST_LENGTH: {
label: 'Длина поста',
desc: 'short ≈ 300-500 зн, medium ≈ 600-1000 зн, long ≈ 1200-2000 зн',
type: 'select',
options: [
{ v: 'short', l: '📏 Короткий (300-500 зн)' },
{ v: 'medium', l: '📄 Средний (600-1000 зн)' },
{ v: 'long', l: '📃 Длинный (1200-2000 зн)' },
],
},
DEFAULT_POST_STYLE: {
label: 'Стиль написания',
desc: 'Тон голоса по умолчанию',
type: 'select',
options: [
{ v: 'informative', l: '📚 Информативный' },
{ v: 'casual', l: '😊 Разговорный' },
{ v: 'professional', l: '👔 Профессиональный' },
{ v: 'storytelling', l: '📖 Сторителлинг' },
{ v: 'provocative', l: '🔥 Провокационный' },
],
},
DEFAULT_POST_GOAL: {
label: 'Цель поста',
desc: 'На что ориентирован контент',
type: 'select',
options: [
{ v: 'educational', l: '🎓 Образовательный' },
{ v: 'entertainment',l: '🎭 Развлекательный' },
{ v: 'sales', l: '💰 Продажи' },
{ v: 'engagement', l: '❤️ Вовлечение' },
{ v: 'news', l: '📰 Новости' },
],
},
DEFAULT_IMAGE_ENABLED: {
label: 'Генерация изображений',
desc: 'Создавать картинки для постов (расходует кредиты)',
type: 'toggle',
},
DEFAULT_EMOJI_ENABLED: {
label: 'Эмодзи в постах',
desc: 'Добавлять эмодзи по умолчанию',
type: 'toggle',
},
DEFAULT_HASHTAGS_IN_POST: {
label: 'Хештеги в постах',
desc: 'Автоматически добавлять хештеги в конец поста',
type: 'toggle',
},
DEFAULT_AUTO_DRAFT_COUNT: {
label: 'Авто-черновиков в день',
desc: 'Для новых каналов с включённой авто-генерацией',
type: 'number',
min: 1, max: 10,
},
DEFAULT_AUTO_DRAFT_TIME: {
label: 'Время генерации черновиков',
desc: 'HH:MM (московское время UTC+3)',
type: 'time',
},
DEFAULT_AI_STYLE_PROMPT: {
label: 'Базовые инструкции стиля',
desc: 'Применяются ко всем каналам поверх индивидуальных настроек',
type: 'textarea',
placeholder: 'Например: Всегда пиши от первого лица. Используй активный залог...',
},
};
const GROUP_ORDER = [
{
title: 'Контент',
keys: ['DEFAULT_POST_LANGUAGE', 'DEFAULT_POST_STYLE', 'DEFAULT_POST_GOAL', 'DEFAULT_POST_LENGTH'],
},
{
title: 'Форматирование',
keys: ['DEFAULT_IMAGE_ENABLED', 'DEFAULT_EMOJI_ENABLED', 'DEFAULT_HASHTAGS_IN_POST'],
},
{
title: 'Авто-черновики',
keys: ['DEFAULT_AUTO_DRAFT_COUNT', 'DEFAULT_AUTO_DRAFT_TIME'],
},
{
title: 'AI-инструкции',
keys: ['DEFAULT_AI_STYLE_PROMPT'],
},
];
export default function AdminContent() {
const [rows, setRows] = useState([]);
const [vals, setVals] = useState({});
const [dirty, setDirty] = useState({});
const [saving, setSaving] = useState({});
const [saved, setSaved] = useState({});
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/settings?category=content').then(r => r.json());
const arr = Array.isArray(res) ? res : [];
setRows(arr);
const v = Object.fromEntries(arr.map(r => [r.key, r.value ?? '']));
setVals(v);
setDirty({});
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
function change(key, val) {
setVals(v => ({ ...v, [key]: val }));
setDirty(d => ({ ...d, [key]: true }));
setSaved(s => ({ ...s, [key]: false }));
}
async function save(key) {
setSaving(s => ({ ...s, [key]: true }));
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: vals[key] }),
}).then(r => r.json());
if (!res.error) {
setDirty(d => ({ ...d, [key]: false }));
setSaved(s => ({ ...s, [key]: true }));
setTimeout(() => setSaved(s => ({ ...s, [key]: false })), 2000);
}
} catch {}
setSaving(s => ({ ...s, [key]: false }));
}
function renderField(key) {
const meta = FIELD_META[key];
if (!meta) return null;
const val = vals[key] ?? '';
const isDirty = dirty[key];
const isSaving = saving[key];
const isSaved = saved[key];
if (meta.type === 'toggle') {
const isOn = val === 'true';
return (
<div key={key} className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div>
<div className="text-sm font-medium">{meta.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
</div>
<button onClick={() => { change(key, isOn ? 'false' : 'true'); setTimeout(() => save(key), 50); }}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isOn ? 'bg-accent' : 'bg-gray-600'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
);
}
if (meta.type === 'select') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<select value={val} onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-full max-w-xs">
{meta.options?.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
</select>
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'number') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<input type="number" min={meta.min} max={meta.max}
value={val} onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-24" />
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'time') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
<input type="time" value={val}
onChange={e => change(key, e.target.value)}
className="input mt-2 text-sm py-1.5 w-32" />
</div>
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
</div>
</div>
);
}
if (meta.type === 'textarea') {
return (
<div key={key} className="py-3 border-b border-border last:border-0">
<label className="text-sm font-medium">{meta.label}</label>
<div className="text-xs text-gray-500 mt-0.5 mb-2">{meta.desc}</div>
<textarea rows={3} value={val}
onChange={e => change(key, e.target.value)}
placeholder={meta.placeholder || ''}
className="input w-full resize-none text-sm" />
{isDirty && (
<button onClick={() => save(key)} disabled={isSaving}
className="mt-2 btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{isSaved && <span className="mt-1 text-xs text-green-400 flex items-center gap-1"><Check className="w-3 h-3" /> Сохранено</span>}
</div>
);
}
return null;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Настройки контента</h2>
<p className="text-xs text-gray-400 mt-0.5">Дефолты для новых каналов. Существующие каналы не затрагиваются.</p>
</div>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Подсказка */}
<div className="card p-3 border-blue-500/20 bg-blue-500/5 flex items-start gap-2">
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-gray-300">
Эти настройки применяются при создании нового канала как стартовые значения.
Каждый канал можно затем настроить индивидуально через вкладку «AI-стиль».
</p>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && GROUP_ORDER.map(group => (
<div key={group.title} className="card p-5">
<h3 className="font-medium text-sm text-gray-400 uppercase tracking-wide mb-1">{group.title}</h3>
{group.keys.map(key => renderField(key))}
</div>
))}
</div>
);
}