2e9f099b95
AdminContent.js: настройки дефолтов по 4 группам Контент: язык, стиль, цель, длина (select) Форматирование: изображения, эмодзи, хештеги (toggle) Авто-черновики: кол-во в день, время генерации AI-инструкции: базовый промт (textarea) Инлайн сохранение — кнопка Сохранить появляется только при изменении Подсказка: изменения применяются только к новым каналам AdminPanel: раздел Контент-дефолты с Sliders иконкой
303 lines
12 KiB
JavaScript
303 lines
12 KiB
JavaScript
'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>
|
|
);
|
|
}
|