forked from admin/zeropost-tool
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:
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
|
||||
|
||||
export async function DELETE(req, { params }) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/${params.id}`, { method: 'DELETE', headers: h(user.id) });
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
|
||||
|
||||
export async function POST(req) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics/generate`, {
|
||||
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
|
||||
|
||||
// GET — список тем
|
||||
export async function GET(req) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const { searchParams } = new URL(req.url);
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics?${searchParams}`, { headers: h(user.id), cache: 'no-store' });
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
// POST — добавить тему
|
||||
export async function POST(req) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/blog-topics`, {
|
||||
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(await res.json(), { status: res.status });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
|
||||
const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
|
||||
|
||||
export async function POST(req) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/email/test`, {
|
||||
method: 'POST', headers: { ...h(user.id), 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(await res.json(), { status: res.status });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user