feat: admin panel — SMTP, topic bank, maintenance mode UI

AdminTopicBank.js: банк тем блога по категориям
  Аккордеон: неиспользованные + использованные темы
  Прогресс-бар использования, кнопка +10 AI (фоновая генерация)
  Мультистрочное добавление (одна строка = одна тема)
AdminPanel: + Email/SMTP + Банк тем блога + Mail иконка
SmtpTestButton: тест отправки прямо в разделе SMTP
API routes: /api/admin/blog-topics, /[id], /generate, /api/admin/email/test
This commit is contained in:
Ник (Claude)
2026-06-13 11:46:10 +03:00
parent 2e9f099b95
commit 789cfe10db
6 changed files with 331 additions and 6 deletions
+49 -6
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, Sliders } from 'lucide-react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
@@ -8,7 +8,8 @@ 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';
import AdminContent from './admin/AdminContent';
import AdminTopicBank from './admin/AdminTopicBank';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
@@ -22,8 +23,10 @@ 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: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
{ id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' },
{ id: 'smtp', label: 'Email / SMTP', icon: Mail, desc: 'Уведомления пользователям' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
];
@@ -76,8 +79,10 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'queue' && <AdminQueue />}
{section === 'logs' && <AdminLogs />}
{section === 'autogen' && <AdminAutogen />}
{section === 'content' && <AdminContent />}
{section === 'plans' && <PlansSection />}
{section === 'content' && <AdminContent />}
{section === 'topicbank' && <AdminTopicBank />}
{section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />}
</div>
@@ -626,3 +631,41 @@ function DashboardSection() {
</div>
);
}
// ── SMTP Test Button ──────────────────────────────────────────
function SmtpTestButton() {
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
async function test() {
if (!email.trim()) return;
setBusy(true);
const res = await fetch('/api/admin/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email }),
}).then(r => r.json());
setBusy(false);
setMsg(res.ok ? '✅ Письмо отправлено' : '❌ ' + (res.error || res.message));
setTimeout(() => setMsg(''), 5000);
}
return (
<div className="card p-4 border-accent/20 bg-accent/5">
<h3 className="font-medium text-sm mb-3">Тест отправки</h3>
<div className="flex gap-2">
<input value={email} onChange={e => setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && test()}
type="email" placeholder="test@example.com"
className="input flex-1 text-sm py-1.5" />
<button onClick={test} disabled={busy || !email.trim()}
className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mail className="w-3.5 h-3.5" />}
Отправить тест
</button>
</div>
{msg && <p className="text-xs mt-2">{msg}</p>}
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Trash2, Loader2, RefreshCw, Zap, Check, ChevronDown, ChevronRight } from 'lucide-react';
const CATEGORY_META = {
'ai-tools': { label: 'AI инструменты', icon: '🤖', color: 'text-purple-400' },
'ai-dev': { label: 'AI разработка', icon: '💻', color: 'text-blue-400' },
'automation': { label: 'Автоматизация', icon: '⚙️', color: 'text-green-400' },
'cybersec': { label: 'Кибербезопасность', icon: '🔒', color: 'text-red-400' },
};
export default function AdminTopicBank() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState({}); // cat → expanded
const [msg, setMsg] = useState('');
const [gen, setGen] = useState({}); // cat → generating
// Форма добавления
const [addCat, setAddCat] = useState('ai-tools');
const [addText, setAddText] = useState('');
const [adding, setAdding] = useState(false);
const [showAdd, setShowAdd] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/blog-topics?includeUsed=true&limit=200').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function addTopic() {
if (!addText.trim()) return;
setAdding(true);
const lines = addText.split('\n').map(l => l.trim()).filter(Boolean);
let added = 0;
for (const topic of lines) {
const res = await fetch('/api/admin/blog-topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: addCat, topic }),
}).then(r => r.json());
if (res.id) added++;
}
setMsg(`✓ Добавлено ${added} тем`);
setAddText(''); setShowAdd(false);
load();
setAdding(false);
setTimeout(() => setMsg(''), 2000);
}
async function deleteTopic(id) {
await fetch(`/api/admin/blog-topics/${id}`, { method: 'DELETE' });
load();
}
async function generate(category) {
setGen(g => ({ ...g, [category]: true }));
await fetch('/api/admin/blog-topics/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, count: 10 }),
});
setMsg(`⚡ Генерирую 10 тем для ${category} (~30с)`);
setTimeout(() => { load(); setMsg(''); }, 35000);
setTimeout(() => setGen(g => ({ ...g, [category]: false })), 35000);
}
const byCategory = {};
for (const t of data?.topics || []) {
if (!byCategory[t.category]) byCategory[t.category] = [];
byCategory[t.category].push(t);
}
const stats = Object.fromEntries((data?.stats || []).map(s => [s.category, s]));
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Банк тем блога</h2>
<p className="text-xs text-gray-400 mt-0.5">Темы для автогенерации статей на zeropost.ru</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
<button onClick={() => setShowAdd(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Добавить
</button>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Форма добавления */}
{showAdd && (
<div className="card p-4 border-accent/30 bg-accent/5 space-y-3">
<h3 className="font-medium text-sm">Добавить темы</h3>
<div className="flex gap-2">
<select value={addCat} onChange={e => setAddCat(e.target.value)} className="input text-sm py-1.5 w-48">
{Object.entries(CATEGORY_META).map(([k, v]) => (
<option key={k} value={k}>{v.icon} {v.label}</option>
))}
</select>
</div>
<textarea rows={4} value={addText} onChange={e => setAddText(e.target.value)}
placeholder={"Одна тема на строку:\nКак использовать Claude API в продакшене\nTop 10 AI инструментов для разработчиков"}
className="input w-full text-sm resize-none" autoFocus />
<div className="flex gap-2">
<button onClick={addTopic} disabled={adding || !addText.trim()}
className="btn-primary px-4 py-1.5 text-sm flex items-center gap-1.5">
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Добавить
</button>
<button onClick={() => { setShowAdd(false); setAddText(''); }}
className="btn-ghost px-3 py-1.5 text-sm">Отмена</button>
</div>
</div>
)}
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{/* Категории */}
{Object.entries(CATEGORY_META).map(([cat, cfg]) => {
const topics = byCategory[cat] || [];
const stat = stats[cat] || { total: 0, unused: 0 };
const isOpen = open[cat];
const unused = topics.filter(t => !t.is_published);
const used = topics.filter(t => t.is_published);
return (
<div key={cat} className="card overflow-hidden">
{/* Header */}
<button onClick={() => setOpen(o => ({ ...o, [cat]: !isOpen }))}
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-surface2/30 transition-colors">
<span className="text-xl">{cfg.icon}</span>
<div className="flex-1 text-left">
<div className={`font-medium ${cfg.color}`}>{cfg.label}</div>
<div className="text-xs text-gray-500 mt-0.5">
{stat.unused} неиспользованных · {used.length} уже опубликованы · итого {stat.total}
</div>
</div>
{/* Прогресс-бар использования */}
<div className="w-24">
<div className="h-1.5 bg-surface2 rounded-full">
<div className={`h-1.5 rounded-full ${cfg.color.replace('text-','bg-')}`}
style={{ width: `${stat.total ? Math.round((used.length/stat.total)*100) : 0}%` }} />
</div>
<div className="text-xs text-gray-500 mt-1 text-right">
{stat.total ? Math.round((used.length/stat.total)*100) : 0}%
</div>
</div>
<button onClick={e => { e.stopPropagation(); generate(cat); }} disabled={gen[cat]}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent shrink-0 ml-2">
{gen[cat] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
+10 AI
</button>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />}
</button>
{/* Список тем */}
{isOpen && (
<div className="border-t border-border">
{/* Неиспользованные */}
{unused.length > 0 && (
<div className="px-5 py-3">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Не использованы ({unused.length})
</div>
<div className="space-y-1">
{unused.map(t => (
<div key={t.id} className="flex items-center gap-2 group py-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-accent/40 shrink-0" />
<span className="text-sm flex-1">{t.topic}</span>
<span className="text-xs text-gray-600">{t.source}</span>
<button onClick={() => deleteTopic(t.id)}
className="opacity-0 group-hover:opacity-100 btn-ghost p-1 text-gray-500 hover:text-red-400">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Использованные */}
{used.length > 0 && (
<div className="px-5 py-3 border-t border-border/50">
<div className="text-xs text-gray-600 uppercase tracking-wide mb-2">
Опубликованы ({used.length})
</div>
<div className="space-y-1 opacity-50">
{used.map(t => (
<div key={t.id} className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500/40 shrink-0" />
<span className="text-sm line-through">{t.topic}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}