forked from admin/zeropost-tool
feat: AdminContent — content defaults UI
AdminContent.js: настройки дефолтов по 4 группам Контент: язык, стиль, цель, длина (select) Форматирование: изображения, эмодзи, хештеги (toggle) Авто-черновики: кол-во в день, время генерации AI-инструкции: базовый промт (textarea) Инлайн сохранение — кнопка Сохранить появляется только при изменении Подсказка: изменения применяются только к новым каналам AdminPanel: раздел Контент-дефолты с Sliders иконкой
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user