Compare commits
10 Commits
92872ed59c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bc413da50 | |||
| 68fb51fc0a | |||
| 33c11049f1 | |||
| 5be51d88f7 | |||
| 836e20e57e | |||
| a3c1fa0c65 | |||
| 789cfe10db | |||
| 2e9f099b95 | |||
| 06340ab24e | |||
| a07cc224a9 |
@@ -0,0 +1,18 @@
|
||||
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 PATCH(req, { params }) {
|
||||
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/autogen/${params.category}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...h(user.id), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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, { params }) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}/run`, {
|
||||
method: 'POST', headers: h(user.id),
|
||||
});
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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/autogen/queue/${params.id}`, {
|
||||
method: 'DELETE', headers: h(user.id),
|
||||
});
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 GET() {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
const res = await fetch(`${ENGINE_URL}/api/admin/autogen`, { headers: h(user.id), cache: 'no-store' });
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
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/autogen/queue`, {
|
||||
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,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 });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 || '';
|
||||
|
||||
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/logs?${searchParams}`,
|
||||
{
|
||||
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Catch-all proxy для /api/admin/zero/* → engine /api/admin/zero/*
|
||||
* Принимает любой метод и любой путь. Auth: session cookie → user.isAdmin.
|
||||
*/
|
||||
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 || '';
|
||||
|
||||
async function proxy(req, { params }) {
|
||||
const user = await requireUser();
|
||||
if (!user?.isAdmin) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
const tail = (params?.path || []).join('/');
|
||||
const qs = req.url.split('?')[1];
|
||||
const url = `${ENGINE_URL}/api/admin/zero${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`;
|
||||
|
||||
const headers = {
|
||||
'x-internal-secret': ENGINE_SECRET,
|
||||
'x-user-id': String(user.id),
|
||||
};
|
||||
|
||||
let body;
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
const ct = req.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
const raw = await req.text();
|
||||
body = raw || undefined;
|
||||
} else {
|
||||
body = await req.text();
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' });
|
||||
const data = await res.json().catch(() => ({ error: 'invalid engine response' }));
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
export const GET = proxy;
|
||||
export const POST = proxy;
|
||||
export const PATCH = proxy;
|
||||
export const PUT = proxy;
|
||||
export const DELETE = proxy;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { engine } from '@/lib/engine';
|
||||
import Header from '@/components/Header';
|
||||
import ChannelHistory from '@/components/ChannelHistory';
|
||||
|
||||
export default async function ChannelHistoryPage({ params }) {
|
||||
const user = await requireUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let channel, posts;
|
||||
try {
|
||||
channel = await engine.getChannel(user.id, id);
|
||||
posts = await engine.listUserPosts(user.id, { channel_id: id, status: 'published', limit: 100 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!channel) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
<ChannelHistory channel={channel} posts={posts || []} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Link from 'next/link';
|
||||
import { Sparkles, Zap, Calendar, BarChart3, MessageCircle, Globe, ArrowRight, Check } from 'lucide-react';
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: Zap, title: 'AI генерация постов', desc: 'Claude пишет посты под твою нишу и стиль. Тексты, которые хочется читать.' },
|
||||
{ icon: Calendar, title: 'Отложенная публикация', desc: 'Планируй контент на неделю вперёд. Автопостинг в нужное время.' },
|
||||
{ icon: Globe, title: 'Telegram, VK, MAX', desc: 'Один интерфейс для всех платформ. Публикуй везде одновременно.' },
|
||||
{ icon: Sparkles, title: 'Авто-черновики', desc: 'Каждое утро 3 новых поста на проверку. Ты только одобряешь лучшее.' },
|
||||
{ icon: BarChart3, title: 'Аналитика канала', desc: 'Видишь что работает. Охват, реакции, лучшее время для публикации.' },
|
||||
{ icon: MessageCircle, title: 'Inbox и AI-ответы', desc: 'Комментарии приходят в одно место. AI предлагает ответы за тебя.' },
|
||||
];
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
name: 'Free', price: 0, credits: 50, channels: 1,
|
||||
features: ['1 канал', '50 кредитов/мес', 'AI генерация постов', 'Планировщик'],
|
||||
cta: 'Начать бесплатно', ctaHref: '/register', accent: false,
|
||||
},
|
||||
{
|
||||
name: 'Starter', price: 490, credits: 500, channels: 2,
|
||||
features: ['2 канала', '500 кредитов/мес', 'Авто-черновики', 'Аналитика', 'Inbox'],
|
||||
cta: 'Попробовать', ctaHref: '/register', accent: true,
|
||||
},
|
||||
{
|
||||
name: 'Pro', price: 1490, credits: 2000, channels: 5,
|
||||
features: ['5 каналов', '2000 кредитов/мес', 'Все платформы', 'Хештеги AI', 'Опросы TG'],
|
||||
cta: 'Выбрать Pro', ctaHref: '/register', accent: false,
|
||||
},
|
||||
{
|
||||
name: 'Business', price: 3990, credits: -1, channels: -1,
|
||||
features: ['Безлимит каналов', 'Безлимит кредитов', 'Приоритетная поддержка', 'API доступ'],
|
||||
cta: 'Связаться', ctaHref: 'mailto:hello@zeropost.ru', accent: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text">
|
||||
{/* Nav */}
|
||||
<nav className="border-b border-border sticky top-0 bg-background/90 backdrop-blur z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 font-bold text-lg">
|
||||
<Sparkles className="w-5 h-5 text-accent" /> ZeroPost
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/login" className="btn-ghost text-sm px-4 py-2">Войти</Link>
|
||||
<Link href="/register" className="btn-primary text-sm px-4 py-2">Попробовать бесплатно</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="max-w-4xl mx-auto px-4 sm:px-6 pt-20 pb-16 text-center">
|
||||
<div className="inline-flex items-center gap-2 text-xs text-accent bg-accent/10 px-3 py-1.5 rounded-full mb-6">
|
||||
<Sparkles className="w-3.5 h-3.5" /> AI-контент для Telegram и VK
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl font-bold leading-tight mb-5">
|
||||
Ведите канал на автопилоте.<br />
|
||||
<span className="text-accent">AI пишет, ты одобряешь.</span>
|
||||
</h1>
|
||||
<p className="text-gray-400 text-lg max-w-2xl mx-auto mb-8">
|
||||
ZeroPost генерирует посты для Telegram и VK, планирует публикации и отвечает на комментарии.
|
||||
Тратьте 10 минут в день вместо 2 часов.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href="/register"
|
||||
className="btn-primary px-6 py-3 text-base flex items-center gap-2">
|
||||
Начать бесплатно <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link href="/login" className="btn-ghost px-6 py-3 text-base">
|
||||
Уже есть аккаунт
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">50 кредитов бесплатно · Без карты</p>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
|
||||
<h2 className="text-2xl font-bold text-center mb-10">Что умеет ZeroPost</h2>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map(f => (
|
||||
<div key={f.title} className="card p-5">
|
||||
<f.icon className="w-8 h-8 text-accent mb-3" />
|
||||
<h3 className="font-semibold mb-2">{f.title}</h3>
|
||||
<p className="text-sm text-gray-400 leading-relaxed">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="max-w-4xl mx-auto px-4 sm:px-6 pb-20">
|
||||
<h2 className="text-2xl font-bold text-center mb-10">Как это работает</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{[
|
||||
{ step: '1', title: 'Добавь канал', desc: 'Подключи Telegram, VK или MAX. Укажи нишу и стиль.' },
|
||||
{ step: '2', title: 'AI генерирует', desc: 'Каждое утро — свежие черновики. Редактируй, одобряй.' },
|
||||
{ step: '3', title: 'Публикуй в один клик', desc: 'Запланируй или публикуй сейчас. Всё само.' },
|
||||
].map(s => (
|
||||
<div key={s.step} className="text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-accent/10 text-accent font-bold text-lg flex items-center justify-center mx-auto mb-3">
|
||||
{s.step}
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">{s.title}</h3>
|
||||
<p className="text-sm text-gray-400">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="max-w-6xl mx-auto px-4 sm:px-6 pb-20">
|
||||
<h2 className="text-2xl font-bold text-center mb-2">Тарифы</h2>
|
||||
<p className="text-gray-400 text-center text-sm mb-10">Начни бесплатно, масштабируй по мере роста</p>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{PLANS.map(plan => (
|
||||
<div key={plan.name} className={`card p-5 flex flex-col ${plan.accent ? 'border-accent bg-accent/5' : ''}`}>
|
||||
{plan.accent && (
|
||||
<div className="text-xs text-accent font-medium mb-2">✨ Популярный</div>
|
||||
)}
|
||||
<div className="font-bold text-lg">{plan.name}</div>
|
||||
<div className="text-3xl font-bold mt-1 mb-1">
|
||||
{plan.price === 0 ? <span className="text-accent">0₽</span> : `${plan.price}₽`}
|
||||
{plan.price > 0 && <span className="text-sm font-normal text-gray-500">/мес</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-4">
|
||||
{plan.credits === -1 ? '∞ кредитов' : `${plan.credits} кредитов/мес`}
|
||||
</div>
|
||||
<ul className="space-y-2 flex-1 mb-5">
|
||||
{plan.features.map(f => (
|
||||
<li key={f} className="text-sm text-gray-300 flex items-center gap-2">
|
||||
<Check className="w-3.5 h-3.5 text-green-400 shrink-0" /> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link href={plan.ctaHref}
|
||||
className={`py-2.5 px-4 rounded-lg text-sm font-medium text-center transition-colors ${
|
||||
plan.accent ? 'btn-primary' : 'btn-ghost border border-border'
|
||||
}`}>
|
||||
{plan.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="max-w-2xl mx-auto px-4 sm:px-6 pb-20 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">Готовы попробовать?</h2>
|
||||
<p className="text-gray-400 mb-6">50 бесплатных кредитов. Без карты. Настройка за 5 минут.</p>
|
||||
<Link href="/register" className="btn-primary px-8 py-3 text-base inline-flex items-center gap-2">
|
||||
Создать аккаунт <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border py-8">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-wrap items-center justify-between gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium text-gray-300">ZeroPost</span>
|
||||
<span>· AI-автоматизация контента</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Link href="/login" className="hover:text-gray-300">Войти</Link>
|
||||
<Link href="/register" className="hover:text-gray-300">Регистрация</Link>
|
||||
<a href="mailto:hello@zeropost.ru" className="hover:text-gray-300">Контакты</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
-30
@@ -15,7 +15,7 @@ const GOAL_LABELS = {
|
||||
|
||||
export default async function HomePage() {
|
||||
const user = await requireUser();
|
||||
if (!user) redirect('/login');
|
||||
if (!user) redirect('/landing');
|
||||
|
||||
let channels = [];
|
||||
try {
|
||||
@@ -43,50 +43,47 @@ export default async function HomePage() {
|
||||
|
||||
{channels.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-surface2 mb-4">
|
||||
<MessageSquare className="w-7 h-7 text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Создай первый канал, чтобы начать генерировать посты
|
||||
</p>
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-4 text-accent opacity-50" />
|
||||
<h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
|
||||
<p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
|
||||
<Link href="/channels/new" className="btn-primary">
|
||||
<Plus className="w-4 h-4" />
|
||||
Создать канал
|
||||
Создать первый канал
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map(ch => (
|
||||
<Link
|
||||
key={ch.id}
|
||||
href={`/channels/${ch.id}`}
|
||||
className="card p-5 hover:border-accent/40 transition-colors group"
|
||||
>
|
||||
<Link key={ch.id} href={`/channels/${ch.id}`} className="card p-5 hover:border-accent/40 transition-colors group">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold group-hover:text-accent transition-colors">
|
||||
{ch.name}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
|
||||
{(ch.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')}
|
||||
<div>
|
||||
<h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
|
||||
{ch.tg_username && (
|
||||
<span className="text-xs text-gray-500">@{ch.tg_username}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ch.platform === 'telegram' ? 'bg-blue-500/20 text-blue-400' :
|
||||
ch.platform === 'vk' ? 'bg-blue-600/20 text-blue-500' :
|
||||
'bg-purple-500/20 text-purple-400'
|
||||
}`}>
|
||||
{ch.platform || 'telegram'}
|
||||
</span>
|
||||
</div>
|
||||
{ch.niche && (
|
||||
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
|
||||
{ch.niche}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
{ch.audience && (
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
{ch.goal && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
Есть ЦА
|
||||
<Target className="w-3 h-3" />
|
||||
{GOAL_LABELS[ch.goal] || ch.goal}
|
||||
</span>
|
||||
)}
|
||||
{ch.style?.example_posts?.length > 0 && (
|
||||
{ch.language && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3 text-accent" />
|
||||
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'}
|
||||
<Users className="w-3 h-3" />
|
||||
{ch.language.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Loader2, Eye, EyeOff, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [pass, setPass] = useState('');
|
||||
const [pass2, setPass2] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [show, setShow] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit() {
|
||||
if (!email.trim() || !pass) { setError('Заполните email и пароль'); return; }
|
||||
if (pass.length < 6) { setError('Пароль минимум 6 символов'); return; }
|
||||
if (pass !== pass2) { setError('Пароли не совпадают'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password: pass, name: name.trim() || undefined, mode: 'register' }),
|
||||
}).then(r => r.json());
|
||||
if (!res.ok) { setError(res.error || 'Ошибка'); setBusy(false); return; }
|
||||
router.push(res.isNew ? '/onboarding' : '/');
|
||||
} catch { setError('Ошибка соединения'); setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
{/* Background glow */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full bg-accent/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-2 text-2xl font-bold">
|
||||
<Sparkles className="w-7 h-7 text-accent" />
|
||||
ZeroPost
|
||||
</Link>
|
||||
<p className="text-gray-400 text-sm mt-2">Создайте аккаунт — это бесплатно</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 sm:p-8 space-y-4">
|
||||
<h1 className="font-bold text-xl text-center">Регистрация</h1>
|
||||
|
||||
<div>
|
||||
<label className="label mb-1.5">Имя <span className="text-gray-500 text-xs">(необязательно)</span></label>
|
||||
<input value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder="Алексей"
|
||||
className="input w-full" autoFocus />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
placeholder="you@example.com"
|
||||
className="input w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-1.5">Пароль</label>
|
||||
<div className="relative">
|
||||
<input type={show ? 'text' : 'password'}
|
||||
value={pass} onChange={e => setPass(e.target.value)}
|
||||
placeholder="Минимум 6 символов"
|
||||
className="input w-full pr-10" />
|
||||
<button onClick={() => setShow(s => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500">
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label mb-1.5">Повторите пароль</label>
|
||||
<input type="password" value={pass2}
|
||||
onChange={e => setPass2(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && submit()}
|
||||
placeholder="Ещё раз"
|
||||
className="input w-full" />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
|
||||
<button onClick={submit} disabled={busy}
|
||||
className="btn-primary w-full py-3 text-base font-medium flex items-center justify-center gap-2">
|
||||
{busy ? <Loader2 className="w-5 h-5 animate-spin" /> : <><Sparkles className="w-4 h-4" />Зарегистрироваться</>}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-gray-500">или</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<Link href="/login" className="btn-ghost w-full py-2.5 text-center text-sm">
|
||||
Уже есть аккаунт? Войти →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Бонус */}
|
||||
<div className="mt-4 text-center text-xs text-gray-500">
|
||||
🎁 При регистрации — <span className="text-accent">50 бесплатных кредитов</span>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-600 mt-3">
|
||||
Регистрируясь, вы принимаете{' '}
|
||||
<Link href="/terms" className="hover:text-gray-400">условия использования</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Coffee, 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';
|
||||
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 AdminTopicBank from './admin/AdminTopicBank';
|
||||
import AdminZero from './admin/AdminZero';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Sidebar navigation
|
||||
@@ -17,7 +22,13 @@ const SECTIONS = [
|
||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||||
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
|
||||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
|
||||
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
|
||||
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
|
||||
{ id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' },
|
||||
{ id: 'zero', label: 'Заметки Зеро', icon: Coffee, desc: 'AI-персонаж в @zeropostru' },
|
||||
{ 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: 'Балансы и кредиты' },
|
||||
];
|
||||
@@ -68,7 +79,13 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||
{section === 'spending' && <SpendingSection />}
|
||||
{section === 'queue' && <AdminQueue />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'logs' && <AdminLogs />}
|
||||
{section === 'autogen' && <AdminAutogen />}
|
||||
{section === 'content' && <AdminContent />}
|
||||
{section === 'topicbank' && <AdminTopicBank />}
|
||||
{section === 'zero' && <AdminZero />}
|
||||
{section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'promos' && <AdminPromos />}
|
||||
{section === 'billing' && <AdminUsers />}
|
||||
</div>
|
||||
@@ -114,7 +131,7 @@ function SettingsSection({ categories }) {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!loaded && !loading) { load(); }
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -244,7 +261,7 @@ function SpendingSection() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!data && !loading) load(period);
|
||||
useEffect(() => { load(period); }, []);
|
||||
|
||||
const totals = data?.totals || {};
|
||||
const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
|
||||
@@ -398,7 +415,7 @@ function PlansSection() {
|
||||
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: false }));
|
||||
}
|
||||
|
||||
if (loading && !plans.length) { load(); }
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
|
||||
|
||||
@@ -493,8 +510,7 @@ function DashboardSection() {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (!data && !loading) load();
|
||||
if (!data && loading) { load(); }
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
|
||||
|
||||
@@ -617,3 +633,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,144 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Clock, Image as ImageIcon, Copy, Check, Search } from 'lucide-react';
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 60) return `${m} мин назад`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function PostCard({ p }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const preview = p.content?.slice(0, 200) || '';
|
||||
const isLong = (p.content?.length || 0) > 200;
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(p.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex flex-col gap-3">
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Clock size={12} />
|
||||
<span>{timeAgo(p.published_at || p.updated_at || p.created_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{copied ? <Check size={13} className="text-green-500" /> : <Copy size={13} />}
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Картинка */}
|
||||
{p.image_url && (
|
||||
<img
|
||||
src={p.image_url}
|
||||
alt=""
|
||||
className="w-full rounded-lg object-cover max-h-48"
|
||||
/>
|
||||
)}
|
||||
{!p.image_url && (
|
||||
<div className="w-full h-10 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center gap-2 px-3">
|
||||
<ImageIcon size={14} className="text-gray-300" />
|
||||
<span className="text-xs text-gray-300">Без изображения</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Текст */}
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap leading-relaxed">
|
||||
{expanded ? p.content : preview}
|
||||
{isLong && !expanded && <span className="text-gray-400">…</span>}
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 text-left"
|
||||
>
|
||||
{expanded ? 'Свернуть' : 'Показать полностью'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChannelHistory({ channel, posts }) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = query.trim()
|
||||
? posts.filter(p => p.content?.toLowerCase().includes(query.toLowerCase()))
|
||||
: posts;
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Навигация */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
href={`/channels/${channel.id}`}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{channel.name}
|
||||
</Link>
|
||||
<span className="text-gray-300 dark:text-gray-700">/</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">История публикаций</span>
|
||||
</div>
|
||||
|
||||
{/* Статистика + поиск */}
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
{posts.length === 0
|
||||
? 'Публикаций пока нет'
|
||||
: `${posts.length} ${posts.length === 1 ? 'публикация' : posts.length < 5 ? 'публикации' : 'публикаций'}`}
|
||||
</p>
|
||||
{posts.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по тексту"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="pl-8 pr-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-300 w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список постов */}
|
||||
{filtered.length === 0 && query ? (
|
||||
<p className="text-center text-sm text-gray-400 py-12">Ничего не найдено</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Clock size={32} className="mx-auto text-gray-200 dark:text-gray-700 mb-3" />
|
||||
<p className="text-sm text-gray-400">Опубликованных постов пока нет</p>
|
||||
<Link
|
||||
href={`/channels/${channel.id}`}
|
||||
className="mt-4 inline-block text-sm text-indigo-500 hover:text-indigo-700"
|
||||
>
|
||||
Создать первый пост →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.map(p => <PostCard key={p.id} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
|
||||
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
|
||||
MessageSquare, Pencil, X, Send, Clock, Search, Camera, ExternalLink, Link2
|
||||
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
|
||||
Search, Camera, ExternalLink, Link2
|
||||
} from 'lucide-react';
|
||||
import PhotoSearchModal from './PhotoSearchModal';
|
||||
import PostPreview from './PostPreview';
|
||||
@@ -351,6 +352,10 @@ export default function ChannelView({ channel }) {
|
||||
</div>
|
||||
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
|
||||
</div>
|
||||
<Link href={`/channels/${channel.id}/history`} className="btn-ghost text-sm">
|
||||
<History className="w-4 h-4" />
|
||||
История
|
||||
</Link>
|
||||
<Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
Настройки
|
||||
|
||||
+19
-1
@@ -2,7 +2,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Sparkles, LogOut, CalendarDays, Coins, FileText } from 'lucide-react';
|
||||
import { Sparkles, LogOut, CalendarDays, Coins, FileText, Settings2 } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
|
||||
export default function Header({ user }) {
|
||||
@@ -63,3 +63,21 @@ export default function Header({ user }) {
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// Публичный хедер для лендинга — отдельный экспорт
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<header className="border-b border-border bg-surface sticky top-0 z-50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between">
|
||||
<Link href="/landing" className="flex items-center gap-2 font-bold">
|
||||
<Sparkles className="w-5 h-5 text-accent" />
|
||||
<span>ZeroPost</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login" className="btn-ghost text-sm px-3 py-1.5">Войти</Link>
|
||||
<Link href="/register" className="btn-primary text-sm px-3 py-1.5">Начать бесплатно</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,16 +290,16 @@ function SettingRow({ row, onSaved }) {
|
||||
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono">{row.key}</code>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{row.description || row.key}
|
||||
</span>
|
||||
{isSecret && (
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
|
||||
secret
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{row.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
|
||||
)}
|
||||
<code className="text-[11px] text-gray-400 font-mono mt-0.5">{row.key}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Loader2, Play, Plus, Trash2, Check, ToggleLeft, ToggleRight, BookOpen, Clock, Zap } from 'lucide-react';
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
'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' },
|
||||
};
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
|
||||
function nextRunIn(nextRunAt) {
|
||||
if (!nextRunAt) return null;
|
||||
const diff = new Date(nextRunAt) - Date.now();
|
||||
if (diff < 0) return 'скоро';
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
if (h > 0) return `через ${h}ч ${m}м`;
|
||||
return `через ${m}м`;
|
||||
}
|
||||
|
||||
export default function AdminAutogen() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState({});
|
||||
const [running, setRunning] = useState({});
|
||||
const [msg, setMsg] = useState('');
|
||||
const [drafts, setDrafts] = useState({}); // category → edited settings
|
||||
|
||||
// Форма добавления темы в очередь
|
||||
const [showQueue, setShowQueue] = useState(false);
|
||||
const [qCat, setQCat] = useState('ai-tools');
|
||||
const [qTopic, setQTopic] = useState('');
|
||||
const [qPriority, setQPriority] = useState(5);
|
||||
const [addingQ, setAddingQ] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/autogen').then(r => r.json());
|
||||
setData(res);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function setDraft(category, field, value) {
|
||||
setDrafts(d => ({
|
||||
...d,
|
||||
[category]: { ...(d[category] || {}), [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function getSetting(category, field) {
|
||||
if (drafts[category]?.[field] !== undefined) return drafts[category][field];
|
||||
const s = data?.settings?.find(s => s.category === category);
|
||||
return s?.[field];
|
||||
}
|
||||
|
||||
async function save(category) {
|
||||
const draft = drafts[category];
|
||||
if (!draft || !Object.keys(draft).length) return;
|
||||
setSaving(s => ({ ...s, [category]: true }));
|
||||
try {
|
||||
const res = await fetch(`/api/admin/autogen/${category}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(draft),
|
||||
}).then(r => r.json());
|
||||
if (res.ok) {
|
||||
setMsg(`✓ ${category} сохранено`);
|
||||
setDrafts(d => { const n = {...d}; delete n[category]; return n; });
|
||||
load();
|
||||
} else setMsg('Ошибка: ' + res.error);
|
||||
} catch { setMsg('Ошибка соединения'); }
|
||||
setSaving(s => ({ ...s, [category]: false }));
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}
|
||||
|
||||
async function runNow(category) {
|
||||
setRunning(r => ({ ...r, [category]: true }));
|
||||
const res = await fetch(`/api/admin/autogen/${category}/run`, { method: 'POST' }).then(r => r.json());
|
||||
setRunning(r => ({ ...r, [category]: false }));
|
||||
setMsg(res.ok ? `⚡ Генерация ${category} запущена (1-2 мин)` : 'Ошибка: ' + res.error);
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
}
|
||||
|
||||
async function addToQueue() {
|
||||
if (!qTopic.trim()) return;
|
||||
setAddingQ(true);
|
||||
const res = await fetch('/api/admin/autogen/queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ category: qCat, topic: qTopic.trim(), priority: qPriority }),
|
||||
}).then(r => r.json());
|
||||
setAddingQ(false);
|
||||
if (res.id) {
|
||||
setMsg('✓ Тема добавлена в очередь');
|
||||
setQTopic('');
|
||||
setShowQueue(false);
|
||||
load();
|
||||
} else setMsg('Ошибка: ' + res.error);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
}
|
||||
|
||||
async function removeFromQueue(id) {
|
||||
await fetch(`/api/admin/autogen/queue/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
}
|
||||
|
||||
const categories = data?.settings?.map(s => s.category) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||
|
||||
{data && (<>
|
||||
{/* Категории */}
|
||||
<div className="space-y-4">
|
||||
{categories.map(cat => {
|
||||
const cfg = CATEGORY_LABELS[cat] || { label: cat, icon: '📝', color: 'text-gray-400' };
|
||||
const s = data.settings.find(s => s.category === cat);
|
||||
const stat = data.byCategory?.[cat];
|
||||
const hasDraft = Object.keys(drafts[cat] || {}).length > 0;
|
||||
const isEnabled = getSetting(cat, 'enabled');
|
||||
const bankSize = data.topicBankSizes?.[cat] || 0;
|
||||
|
||||
return (
|
||||
<div key={cat} className={`card p-5 ${!isEnabled ? 'opacity-60' : ''}`}>
|
||||
{/* Заголовок категории */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{cfg.icon}</span>
|
||||
<div>
|
||||
<div className={`font-semibold ${cfg.color}`}>{cfg.label}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Запустить сейчас */}
|
||||
<button onClick={() => runNow(cat)} disabled={running[cat]}
|
||||
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent">
|
||||
{running[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
Запустить
|
||||
</button>
|
||||
{/* Toggle */}
|
||||
<button onClick={() => {
|
||||
setDraft(cat, 'enabled', !isEnabled);
|
||||
setTimeout(() => save(cat), 50);
|
||||
}}>
|
||||
{isEnabled
|
||||
? <ToggleRight className="w-7 h-7 text-green-400" />
|
||||
: <ToggleLeft className="w-7 h-7 text-gray-500" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4 text-xs">
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-base">{stat?.cnt_7d || 0}</div>
|
||||
<div className="text-gray-500 mt-0.5">статей за 7 дней</div>
|
||||
</div>
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-base">{bankSize}</div>
|
||||
<div className="text-gray-500 mt-0.5">тем в банке</div>
|
||||
</div>
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-sm truncate">{nextRunIn(s?.next_run_at) || '—'}</div>
|
||||
<div className="text-gray-500 mt-0.5">следующий запуск</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Статей в день</label>
|
||||
<select value={getSetting(cat, 'per_day') ?? 1}
|
||||
onChange={e => setDraft(cat, 'per_day', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5">
|
||||
{[1,2,3,4,5].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Час запуска (0-23)</label>
|
||||
<input type="number" min={0} max={23}
|
||||
value={getSetting(cat, 'run_hour') ?? 8}
|
||||
onChange={e => setDraft(cat, 'run_hour', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Минута</label>
|
||||
<input type="number" min={0} max={59}
|
||||
value={getSetting(cat, 'run_minute') ?? 0}
|
||||
onChange={e => setDraft(cat, 'run_minute', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Последний + следующий */}
|
||||
<div className="flex gap-4 mt-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Последний запуск: {fmtDate(s?.last_run_at)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Следующий: {fmtDate(s?.next_run_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка сохранить (если есть изменения) */}
|
||||
{hasDraft && (
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center gap-2">
|
||||
<button onClick={() => save(cat)} disabled={saving[cat]}
|
||||
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||
{saving[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={() => setDrafts(d => { const n = {...d}; delete n[cat]; return n; })}
|
||||
className="btn-ghost text-sm px-3 py-1.5 text-gray-500">Отмена</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Очередь тем */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-accent" /> Очередь тем
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Темы из очереди публикуются раньше тем из банка</p>
|
||||
</div>
|
||||
<button onClick={() => setShowQueue(v => !v)}
|
||||
className="btn-ghost text-sm px-2.5 py-1.5 flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Форма добавления */}
|
||||
{showQueue && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-accent/5 border border-accent/20 space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select value={qCat} onChange={e => setQCat(e.target.value)} className="input text-sm py-1.5">
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.icon} {v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={qTopic} onChange={e => setQTopic(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToQueue()}
|
||||
placeholder="Тема статьи..." className="input text-sm py-1.5 col-span-2" autoFocus />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 w-16">Приоритет:</label>
|
||||
<input type="range" min={1} max={10} value={qPriority}
|
||||
onChange={e => setQPriority(+e.target.value)} className="flex-1" />
|
||||
<span className="text-xs text-gray-400 w-4">{qPriority}</span>
|
||||
<button onClick={addToQueue} disabled={addingQ || !qTopic.trim()}
|
||||
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1">
|
||||
{addingQ ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
Добавить
|
||||
</button>
|
||||
<button onClick={() => setShowQueue(false)} className="btn-ghost p-1.5">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список тем в очереди */}
|
||||
{data.queue?.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
Очередь пуста — используются темы из банка
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{(data.queue || []).map(item => {
|
||||
const cfg = CATEGORY_LABELS[item.category] || { icon: '📝', color: 'text-gray-400' };
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-surface2 hover:bg-surface2/80">
|
||||
<span className="text-sm">{cfg.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-200 truncate">{item.topic}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{item.category} · приоритет {item.priority} · {fmtDate(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => removeFromQueue(item.id)}
|
||||
className="btn-ghost p-1.5 text-gray-500 hover:text-red-400 shrink-0">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { RefreshCw, Loader2, AlertTriangle, Cpu, Send, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
const SOURCE_CONFIG = {
|
||||
generation: { icon: '⚙️', label: 'Генерация', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
ai_provider:{ icon: '🤖', label: 'AI провайдер', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
publish: { icon: '📤', label: 'Публикация', color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||
};
|
||||
|
||||
// Категоризируем ошибку по тексту
|
||||
function classifyError(msg) {
|
||||
if (!msg) return { type: 'unknown', label: 'Неизвестно', color: 'text-gray-400' };
|
||||
const m = msg.toLowerCase();
|
||||
if (m.includes('timeout')) return { type: 'timeout', label: 'Таймаут', color: 'text-yellow-400' };
|
||||
if (m.includes('rate limit')) return { type: 'ratelimit',label: 'Rate limit', color: 'text-orange-400' };
|
||||
if (m.includes('not supported')) return { type: 'model', label: 'Модель', color: 'text-red-400' };
|
||||
if (m.includes('empty response')) return { type: 'empty', label: 'Пустой ответ', color: 'text-red-400' };
|
||||
if (m.includes('network') || m.includes('connect')) return { type: 'network', label: 'Сеть', color: 'text-orange-400' };
|
||||
if (m.includes('auth') || m.includes('key') || m.includes('401')) return { type: 'auth', label: 'Авторизация', color: 'text-red-400' };
|
||||
return { type: 'other', label: 'Другое', color: 'text-gray-400' };
|
||||
}
|
||||
|
||||
function timeAgo(s) {
|
||||
const diff = Date.now() - new Date(s);
|
||||
if (diff < 60000) return Math.floor(diff / 1000) + 'с назад';
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + 'м назад';
|
||||
if (diff < 86400000)return Math.floor(diff / 3600000) + 'ч назад';
|
||||
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||
}
|
||||
|
||||
export default function AdminLogs() {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // all | generation | ai_provider | publish
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/logs?limit=100').then(r => r.json());
|
||||
setData(res);
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const errors = (data?.errors || []).filter(e =>
|
||||
filter === 'all' || e.source === filter
|
||||
);
|
||||
|
||||
const counts = (data?.errors || []).reduce((acc, e) => {
|
||||
acc[e.source] = (acc[e.source] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Логи ошибок</h2>
|
||||
<button onClick={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
|
||||
)}
|
||||
|
||||
{data && (<>
|
||||
{/* Топ ошибок */}
|
||||
{data.topErrors?.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Частые ошибки</p>
|
||||
<div className="space-y-2">
|
||||
{data.topErrors.map((e, i) => {
|
||||
const cls = classifyError(e.msg);
|
||||
const pct = Math.round((e.cnt / data.total) * 100);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<div className="w-16 text-right text-xs font-mono text-gray-400">{e.cnt}×</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-gray-200 truncate">{e.msg}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<div className="flex-1 h-1 bg-surface2 rounded-full">
|
||||
<div className="h-1 bg-accent/60 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs ${cls.color}`}>{cls.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Статистика + фильтр */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={() => setFilter('all')}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${filter === 'all' ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'}`}>
|
||||
Все ({data.total})
|
||||
</button>
|
||||
{Object.entries(SOURCE_CONFIG).map(([k, cfg]) => (
|
||||
<button key={k} onClick={() => setFilter(k)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs transition-colors flex items-center gap-1 ${filter === k ? `${cfg.bg} ${cfg.color} font-medium` : 'text-gray-500 hover:text-gray-300'}`}>
|
||||
{cfg.icon} {cfg.label} {counts[k] ? `(${counts[k]})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Список */}
|
||||
{errors.length === 0 && (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<AlertTriangle className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||
<div className="text-sm">Ошибок не найдено 🎉</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{errors.map((err, i) => {
|
||||
const src = SOURCE_CONFIG[err.source] || SOURCE_CONFIG.generation;
|
||||
const cls = classifyError(err.message);
|
||||
const isOpen = expanded === i;
|
||||
const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'Unknown error';
|
||||
|
||||
return (
|
||||
<div key={i} className={`card border-l-2 overflow-hidden transition-all ${
|
||||
cls.type === 'timeout' ? 'border-yellow-500/40' :
|
||||
cls.type === 'auth' ? 'border-red-500/60' :
|
||||
cls.type === 'model' ? 'border-red-400/40' :
|
||||
'border-gray-600'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setExpanded(isOpen ? null : i)}
|
||||
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface2/30 transition-colors"
|
||||
>
|
||||
<span className="text-base shrink-0 mt-0.5">{src.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<span className={`text-xs font-medium ${src.color}`}>{src.label}</span>
|
||||
<span className="text-xs text-gray-500">·</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{err.operation}</span>
|
||||
{err.user_email && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">·</span>
|
||||
<span className="text-xs text-gray-500">{err.user_email}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-600 ml-auto">{timeAgo(err.created_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-200">{shortMsg}</div>
|
||||
{err.context && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 truncate">{err.context}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 mt-1">
|
||||
{isOpen
|
||||
? <ChevronUp className="w-3.5 h-3.5 text-gray-500" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-gray-500" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="px-4 pb-3 border-t border-border bg-surface2/30">
|
||||
<div className="mt-2 space-y-1.5 text-xs">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">ID:</span>
|
||||
<span className="font-mono text-gray-300">{err.entity_id}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Источник:</span>
|
||||
<span className="text-gray-300">{err.source}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Операция:</span>
|
||||
<span className="font-mono text-gray-300">{err.operation}</span>
|
||||
</div>
|
||||
{err.context && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Контекст:</span>
|
||||
<span className="text-gray-300 break-all">{err.context}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Ошибка:</span>
|
||||
<span className="text-red-300 break-all font-mono">{err.message}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Время:</span>
|
||||
<span className="text-gray-300">
|
||||
{new Date(err.created_at).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-500 w-20 shrink-0">Тип ошибки:</span>
|
||||
<span className={`${cls.color} font-medium`}>{cls.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Действие для ошибок модели */}
|
||||
{cls.type === 'model' && err.source === 'ai_provider' && (
|
||||
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20 text-xs text-red-300">
|
||||
💡 Проверь настройку AI_IMAGE_MODEL_VIA_RESPONSES в{' '}
|
||||
<a href="/system?section=settings" className="underline">Настройках API</a>
|
||||
</div>
|
||||
)}
|
||||
{cls.type === 'timeout' && (
|
||||
<div className="mt-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-xs text-yellow-300">
|
||||
💡 Таймаут {err.operation?.includes('chat') ? 'текстовой генерации' : 'изображений'} — возможны проблемы у провайдера
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Loader2, RefreshCw, Plus, Check, X, Edit3, Save, Zap, Send,
|
||||
Coffee, Bug, Wrench, MessageCircle, Sparkles, ChevronDown, Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Метаданные для UI
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const STATUS_META = {
|
||||
draft: { label: 'Черновик', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' },
|
||||
approved: { label: 'Одобрено', color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' },
|
||||
sending: { label: 'Отправка…', color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30' },
|
||||
published: { label: 'Опубликовано', color: 'text-emerald-400',bg: 'bg-emerald-500/10',border: 'border-emerald-500/30' },
|
||||
failed: { label: 'Ошибка', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30' },
|
||||
skipped: { label: 'Пропущено', color: 'text-gray-500', bg: 'bg-surface2', border: 'border-border' },
|
||||
};
|
||||
|
||||
const BUCKET_ICON = {
|
||||
bug_story: Bug,
|
||||
tools: Wrench,
|
||||
coffee_thoughts: Coffee,
|
||||
ai_industry: Sparkles,
|
||||
};
|
||||
|
||||
function bucketIcon(key) { return BUCKET_ICON[key] || MessageCircle; }
|
||||
|
||||
function mskTime(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function wordCount(s) { return s ? s.trim().split(/\s+/).length : 0; }
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Main component
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
export default function AdminZero() {
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [buckets, setBuckets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
// Генерация
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm, setGenForm] = useState({ channel_id: 1, force_bucket: '', allow_today_dup: false });
|
||||
const [generating, setGen] = useState(false);
|
||||
|
||||
// Edit
|
||||
const [editId, setEditId] = useState(null);
|
||||
const [editText, setEditText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Regenerate
|
||||
const [regenId, setRegenId] = useState(null);
|
||||
const [regenBucket, setRegenBucket] = useState('');
|
||||
|
||||
// ────── загрузка ──────
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch('/api/admin/zero/notes?limit=100').then(r => r.json());
|
||||
setNotes(r.items || []);
|
||||
} catch (e) {
|
||||
setMsg('Ошибка загрузки: ' + e.message);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
fetch('/api/admin/zero/buckets').then(r => r.json()).then(r => setBuckets(r.buckets || []));
|
||||
}, [load]);
|
||||
|
||||
const flash = (text) => {
|
||||
setMsg(text);
|
||||
setTimeout(() => setMsg(''), 3000);
|
||||
};
|
||||
|
||||
// ────── actions ──────
|
||||
async function generate() {
|
||||
setGen(true);
|
||||
try {
|
||||
const r = await fetch('/api/admin/zero/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
channel_id: Number(genForm.channel_id),
|
||||
force_bucket: genForm.force_bucket || undefined,
|
||||
allow_today_dup: genForm.allow_today_dup,
|
||||
}),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Ошибка генерации');
|
||||
flash(`✓ Черновик #${data.note.id} создан · ведро ${data.note.theme_bucket}`);
|
||||
setGenOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
flash('✗ ' + e.message);
|
||||
}
|
||||
setGen(false);
|
||||
}
|
||||
|
||||
async function approve(id) {
|
||||
const r = await fetch(`/api/admin/zero/notes/${id}/approve`, { method: 'POST' });
|
||||
if (r.ok) { flash(`✓ Заметка #${id} одобрена`); load(); }
|
||||
else { const d = await r.json(); flash('✗ ' + (d.error || 'fail')); }
|
||||
}
|
||||
|
||||
async function skip(id) {
|
||||
if (!confirm('Пропустить эту заметку? Она не будет опубликована.')) return;
|
||||
const r = await fetch(`/api/admin/zero/notes/${id}/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: 'skipped by admin via UI' }),
|
||||
});
|
||||
if (r.ok) { flash(`Заметка #${id} пропущена`); load(); }
|
||||
}
|
||||
|
||||
async function regenerate(id) {
|
||||
const r = await fetch(`/api/admin/zero/notes/${id}/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force_bucket: regenBucket || undefined }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (r.ok) { flash(`✓ Регенерация #${id} → #${data.note?.id}`); setRegenId(null); setRegenBucket(''); load(); }
|
||||
else flash('✗ ' + data.error);
|
||||
}
|
||||
|
||||
function startEdit(note) {
|
||||
setEditId(note.id);
|
||||
setEditText(note.content);
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await fetch(`/api/admin/zero/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editText }),
|
||||
});
|
||||
if (r.ok) { flash('✓ Сохранено'); setEditId(null); load(); }
|
||||
else { const d = await r.json(); flash('✗ ' + d.error); }
|
||||
} catch (e) { flash('✗ ' + e.message); }
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
// ────── filter + counts ──────
|
||||
const counts = notes.reduce((acc, n) => { acc[n.status] = (acc[n.status] || 0) + 1; return acc; }, {});
|
||||
const filtered = filter === 'all' ? notes : notes.filter(n => n.status === filter);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* HEADER */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Заметки от Зеро</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Короткие посты в @zeropostru от AI-персонажа · 13:00 МСК ежедневно</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{msg && <span className="text-sm text-emerald-400">{msg}</span>}
|
||||
<button onClick={() => setGenOpen(v => !v)} className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Сгенерировать
|
||||
</button>
|
||||
<button onClick={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GENERATE FORM */}
|
||||
{genOpen && (
|
||||
<div className="card p-4 border-accent/30 bg-accent/5 space-y-3">
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" /> Новый черновик
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label text-xs">Канал (channel_id)</label>
|
||||
<input type="number" value={genForm.channel_id}
|
||||
onChange={e => setGenForm(f => ({ ...f, channel_id: e.target.value }))}
|
||||
className="input text-sm py-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs">Ведро темы</label>
|
||||
<select value={genForm.force_bucket}
|
||||
onChange={e => setGenForm(f => ({ ...f, force_bucket: e.target.value }))}
|
||||
className="input text-sm py-1.5">
|
||||
<option value="">Случайное (anti-repeat)</option>
|
||||
{buckets.map(b => <option key={b.key} value={b.key}>{b.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={genForm.allow_today_dup}
|
||||
onChange={e => setGenForm(f => ({ ...f, allow_today_dup: e.target.checked }))} />
|
||||
Разрешить второй черновик за день
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={generate} disabled={generating}
|
||||
className="btn-primary px-4 py-1.5 text-sm flex items-center gap-1.5">
|
||||
{generating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />}
|
||||
{generating ? 'Генерация ~20-30 сек…' : 'Запустить'}
|
||||
</button>
|
||||
<button onClick={() => setGenOpen(false)} className="btn-ghost px-3 py-1.5 text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FILTER TABS */}
|
||||
<div className="flex flex-wrap gap-1.5 text-sm">
|
||||
<FilterTab active={filter === 'all'} onClick={() => setFilter('all')} label={`Все · ${notes.length}`} />
|
||||
{Object.entries(STATUS_META).map(([key, meta]) => {
|
||||
const cnt = counts[key] || 0;
|
||||
if (cnt === 0 && key !== 'draft' && key !== 'approved') return null;
|
||||
return <FilterTab key={key} active={filter === key} onClick={() => setFilter(key)}
|
||||
label={`${meta.label} · ${cnt}`} colorClass={meta.color} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* LIST */}
|
||||
{loading && notes.length === 0 && (
|
||||
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="card p-8 text-center text-sm text-gray-400">
|
||||
Заметок в этом разделе нет. Жми «Сгенерировать» чтобы создать черновик.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map(note => (
|
||||
<NoteCard key={note.id} note={note}
|
||||
buckets={buckets}
|
||||
isEditing={editId === note.id}
|
||||
editText={editText}
|
||||
setEditText={setEditText}
|
||||
saving={saving}
|
||||
onStartEdit={() => startEdit(note)}
|
||||
onCancelEdit={() => setEditId(null)}
|
||||
onSaveEdit={() => saveEdit(note.id)}
|
||||
onApprove={() => approve(note.id)}
|
||||
onSkip={() => skip(note.id)}
|
||||
isRegen={regenId === note.id}
|
||||
regenBucket={regenBucket}
|
||||
setRegenBucket={setRegenBucket}
|
||||
onRegenStart={() => { setRegenId(note.id); setRegenBucket(''); }}
|
||||
onRegenCancel={() => setRegenId(null)}
|
||||
onRegenConfirm={() => regenerate(note.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Sub-components
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function FilterTab({ active, onClick, label, colorClass }) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className={`px-3 py-1 rounded-md border transition-colors ${
|
||||
active ? 'border-accent text-accent bg-accent/10' : 'border-border text-gray-400 hover:bg-surface2'
|
||||
} ${colorClass || ''}`}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCard({
|
||||
note, buckets,
|
||||
isEditing, editText, setEditText, saving, onStartEdit, onCancelEdit, onSaveEdit,
|
||||
onApprove, onSkip,
|
||||
isRegen, regenBucket, setRegenBucket, onRegenStart, onRegenCancel, onRegenConfirm,
|
||||
}) {
|
||||
const meta = STATUS_META[note.status] || STATUS_META.draft;
|
||||
const Icon = bucketIcon(note.theme_bucket);
|
||||
const bucketLabel = buckets.find(b => b.key === note.theme_bucket)?.label || note.theme_bucket;
|
||||
const canApprove = note.status === 'draft';
|
||||
const canEdit = ['draft', 'approved'].includes(note.status);
|
||||
const canRegen = ['draft', 'failed'].includes(note.status);
|
||||
const canSkip = ['draft', 'approved'].includes(note.status);
|
||||
|
||||
return (
|
||||
<div className={`card p-4 border ${meta.border}`}>
|
||||
{/* HEADER */}
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<Icon className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-mono text-gray-500">#{note.id}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded ${meta.bg} ${meta.color} font-medium`}>{meta.label}</span>
|
||||
<span className="text-gray-400">· {bucketLabel}</span>
|
||||
{note.pose && <span className="text-gray-500">· поза: {note.pose}</span>}
|
||||
<span className="text-gray-500">· {note.status === 'published'
|
||||
? `опубликовано ${mskTime(note.published_at)}`
|
||||
: `на ${mskTime(note.scheduled_at)} МСК`}</span>
|
||||
</div>
|
||||
{note.theme && <div className="text-xs text-gray-400 mt-1 truncate">тема: {note.theme}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTENT or EDIT */}
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea value={editText} onChange={e => setEditText(e.target.value)}
|
||||
rows={Math.max(6, editText.split('\n').length + 1)}
|
||||
className="input w-full text-sm font-mono resize-y" />
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{wordCount(editText)} слов · {editText.length} символов</span>
|
||||
<div className="flex-1" />
|
||||
<button onClick={onCancelEdit} className="btn-ghost px-3 py-1 text-xs flex items-center gap-1">
|
||||
<X className="w-3 h-3" /> Отмена
|
||||
</button>
|
||||
<button onClick={onSaveEdit} disabled={saving}
|
||||
className="btn-primary px-3 py-1 text-xs flex items-center gap-1">
|
||||
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed">{note.content}</div>
|
||||
)}
|
||||
|
||||
{/* ERROR */}
|
||||
{note.error && (
|
||||
<div className="mt-3 text-xs text-red-400 bg-red-500/5 border border-red-500/20 rounded p-2">
|
||||
Ошибка: {note.error}
|
||||
{note.attempts > 0 && <span className="text-gray-500"> · попыток: {note.attempts}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* REGEN PICKER */}
|
||||
{isRegen && (
|
||||
<div className="mt-3 p-3 border border-accent/30 bg-accent/5 rounded-lg space-y-2">
|
||||
<div className="text-xs text-gray-400">Выбери ведро для перегенерации (или оставь пусто для anti-repeat):</div>
|
||||
<select value={regenBucket} onChange={e => setRegenBucket(e.target.value)}
|
||||
className="input text-sm py-1.5">
|
||||
<option value="">Случайное (anti-repeat)</option>
|
||||
{buckets.map(b => <option key={b.key} value={b.key}>{b.label}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onRegenConfirm} className="btn-primary px-3 py-1 text-xs flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" /> Перегенерировать
|
||||
</button>
|
||||
<button onClick={onRegenCancel} className="btn-ghost px-3 py-1 text-xs">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ACTIONS */}
|
||||
{!isEditing && !isRegen && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
{canApprove && (
|
||||
<button onClick={onApprove} className="btn-primary px-3 py-1 flex items-center gap-1">
|
||||
<Check className="w-3 h-3" /> Одобрить
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button onClick={onStartEdit} className="btn-ghost px-3 py-1 flex items-center gap-1">
|
||||
<Edit3 className="w-3 h-3" /> Редактировать
|
||||
</button>
|
||||
)}
|
||||
{canRegen && (
|
||||
<button onClick={onRegenStart} className="btn-ghost px-3 py-1 flex items-center gap-1">
|
||||
<RefreshCw className="w-3 h-3" /> Перегенерировать
|
||||
</button>
|
||||
)}
|
||||
{canSkip && (
|
||||
<button onClick={onSkip} className="btn-ghost px-3 py-1 flex items-center gap-1 text-gray-500 hover:text-red-400">
|
||||
<Trash2 className="w-3 h-3" /> Пропустить
|
||||
</button>
|
||||
)}
|
||||
{note.status === 'published' && note.channel_message_id && (
|
||||
<span className="text-gray-500 flex items-center gap-1">
|
||||
<Send className="w-3 h-3" /> TG msg #{note.channel_message_id}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-gray-600 font-mono">
|
||||
{note.model || ''}
|
||||
{note.tokens_in ? ` · ${note.tokens_in}→${note.tokens_out} tok` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user