Compare commits

...

7 Commits

Author SHA1 Message Date
Aleksei Pavlov 5bc413da50 feat(zero): admin UI for Zero notes management
Adds new admin section 'Заметки Зеро' () with:
  - manual 'Generate' button with channel/bucket/allow-dup form
  - status filter tabs with counters (draft/approved/published/failed/skipped)
  - per-note actions: approve / edit inline / regenerate with bucket pick / skip
  - status-colored cards with bucket icon, pose, scheduled time MSK
  - error display with attempt counter
  - tokens & model footer

Files:
  app/api/admin/zero/[...path]/route.js  catch-all proxy → engine
  components/admin/AdminZero.js          main component
  components/AdminPanel.js               +section in sidebar
2026-06-19 10:53:00 +03:00
Alexey Pavlov 68fb51fc0a fix: system settings — show description as title, key as small monospace hint 2026-06-15 10:39:03 +03:00
Alexey Pavlov 33c11049f1 merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2 2026-06-15 10:28:42 +03:00
Alexey Pavlov 5be51d88f7 feat: channel history page — published posts with search 2026-06-15 10:28:07 +03:00
Ник (Claude) 836e20e57e fix: React error #301 — load() in render body → useEffect
Причина: load() вызывался прямо в теле компонента (не в useEffect),
что триггерило setState → re-render → load() → бесконечный цикл.

Исправлено в AdminPanel.js:
  SpendingSection: if (!data && !loading) load(period) → useEffect
  DashboardSection: if (!data...) { load() } × 2 → useEffect
  SettingsSection: if (!loaded && !loading) → useEffect
  PlansSection: if (loading && !plans.length) → useEffect

Добавлен useEffect в import.
2026-06-13 23:31:51 +03:00
Ник (Claude) a3c1fa0c65 feat: registration + public landing page
/register: полноценная страница регистрации с валидацией
  email, пароль (6+ символов), подтверждение, имя (optional)
  После регистрации → /onboarding (создать первый канал)
  50 кредитов при регистрации
/landing: публичный лендинг для незалогиненных
  Hero, Features (6), How it works (3 шага), Pricing (4 тарифа), CTA, Footer
page.js: незалогиненный → redirect /landing (не /login)
Header: Settings2 в импорт, PublicHeader экспорт для лендинга
2026-06-13 15:05:46 +03:00
Ник (Claude) 789cfe10db feat: admin panel — SMTP, topic bank, maintenance mode UI
AdminTopicBank.js: банк тем блога по категориям
  Аккордеон: неиспользованные + использованные темы
  Прогресс-бар использования, кнопка +10 AI (фоновая генерация)
  Мультистрочное добавление (одна строка = одна тема)
AdminPanel: + Email/SMTP + Банк тем блога + Mail иконка
SmtpTestButton: тест отправки прямо в разделе SMTP
API routes: /api/admin/blog-topics, /[id], /generate, /api/admin/email/test
2026-06-13 11:46:10 +03:00
16 changed files with 1303 additions and 48 deletions
+13
View File
@@ -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());
}
+26
View File
@@ -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 });
}
+16
View File
@@ -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 });
}
+46
View File
@@ -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;
+28
View File
@@ -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 || []} />
</>
);
}
+173
View File
@@ -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
View File
@@ -15,7 +15,7 @@ const GOAL_LABELS = {
export default async function HomePage() { export default async function HomePage() {
const user = await requireUser(); const user = await requireUser();
if (!user) redirect('/login'); if (!user) redirect('/landing');
let channels = []; let channels = [];
try { try {
@@ -43,50 +43,47 @@ export default async function HomePage() {
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="card p-12 text-center"> <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-12 h-12 mx-auto mb-4 text-accent opacity-50" />
<MessageSquare className="w-7 h-7 text-gray-500" /> <h2 className="text-xl font-semibold mb-2">Нет каналов</h2>
</div> <p className="text-gray-500 mb-6">Добавь первый канал чтобы начать генерировать контент</p>
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
<p className="text-sm text-gray-500 mb-6">
Создай первый канал, чтобы начать генерировать посты
</p>
<Link href="/channels/new" className="btn-primary"> <Link href="/channels/new" className="btn-primary">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Создать канал Создать первый канал
</Link> </Link>
</div> </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 => ( {channels.map(ch => (
<Link <Link key={ch.id} href={`/channels/${ch.id}`} className="card p-5 hover:border-accent/40 transition-colors group">
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"> <div className="flex items-start justify-between mb-3">
<h3 className="font-semibold group-hover:text-accent transition-colors"> <div>
{ch.name} <h3 className="font-semibold group-hover:text-accent transition-colors">{ch.name}</h3>
</h3> {ch.tg_username && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400"> <span className="text-xs text-gray-500">@{ch.tg_username}</span>
{(ch.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')} )}
</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> </span>
</div> </div>
{ch.niche && ( {ch.niche && (
<p className="text-xs text-gray-500 line-clamp-2 mb-3"> <p className="text-sm text-gray-400 mb-3 line-clamp-2">{ch.niche}</p>
{ch.niche}
</p>
)} )}
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-3 text-xs text-gray-500">
{ch.audience && ( {ch.goal && (
<span className="flex items-center gap-1"> <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> </span>
)} )}
{ch.style?.example_posts?.length > 0 && ( {ch.language && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Target className="w-3 h-3 text-accent" /> <Users className="w-3 h-3" />
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'} {ch.language.toUpperCase()}
</span> </span>
)} )}
</div> </div>
+121
View File
@@ -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>
);
}
+57 -12
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders } from 'lucide-react'; import { 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 Link from 'next/link';
import AdminBilling from './admin/AdminBilling'; import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers'; import AdminUsers from './admin/AdminUsers';
@@ -8,7 +8,9 @@ import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue'; import AdminQueue from './admin/AdminQueue';
import AdminLogs from './admin/AdminLogs'; import AdminLogs from './admin/AdminLogs';
import AdminAutogen from './admin/AdminAutogen'; import AdminAutogen from './admin/AdminAutogen';
import AdminContent from './admin/AdminContent'; import AdminContent from './admin/AdminContent';
import AdminTopicBank from './admin/AdminTopicBank';
import AdminZero from './admin/AdminZero';
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Sidebar navigation // Sidebar navigation
@@ -22,8 +24,11 @@ const SECTIONS = [
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' }, { id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' }, { id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' }, { id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' }, { id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, 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: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' }, { id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
]; ];
@@ -76,8 +81,11 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'queue' && <AdminQueue />} {section === 'queue' && <AdminQueue />}
{section === 'logs' && <AdminLogs />} {section === 'logs' && <AdminLogs />}
{section === 'autogen' && <AdminAutogen />} {section === 'autogen' && <AdminAutogen />}
{section === 'content' && <AdminContent />} {section === 'content' && <AdminContent />}
{section === 'plans' && <PlansSection />} {section === 'topicbank' && <AdminTopicBank />}
{section === 'zero' && <AdminZero />}
{section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />} {section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />} {section === 'billing' && <AdminUsers />}
</div> </div>
@@ -123,7 +131,7 @@ function SettingsSection({ categories }) {
setLoading(false); setLoading(false);
} }
if (!loaded && !loading) { load(); } useEffect(() => { load(); }, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -253,7 +261,7 @@ function SpendingSection() {
setLoading(false); setLoading(false);
} }
if (!data && !loading) load(period); useEffect(() => { load(period); }, []);
const totals = data?.totals || {}; const totals = data?.totals || {};
const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech'); const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
@@ -407,7 +415,7 @@ function PlansSection() {
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: false })); 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' }; const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
@@ -502,8 +510,7 @@ function DashboardSection() {
setLoading(false); setLoading(false);
} }
if (!data && !loading) load(); useEffect(() => { load(); }, []);
if (!data && loading) { load(); }
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' }; const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
@@ -626,3 +633,41 @@ function DashboardSection() {
</div> </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>
);
}
+144
View File
@@ -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>
);
}
+6 -1
View File
@@ -4,7 +4,8 @@ import Link from 'next/link';
import { import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, 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'; } from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal'; import PhotoSearchModal from './PhotoSearchModal';
import PostPreview from './PostPreview'; import PostPreview from './PostPreview';
@@ -351,6 +352,10 @@ export default function ChannelView({ channel }) {
</div> </div>
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>} {channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
</div> </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"> <Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Настройки Настройки
+19 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; 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'; import ThemeToggle from './ThemeToggle';
export default function Header({ user }) { export default function Header({ user }) {
@@ -63,3 +63,21 @@ export default function Header({ user }) {
</header> </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>
);
}
+4 -4
View File
@@ -290,16 +290,16 @@ function SettingRow({ row, onSaved }) {
<div className="flex items-start justify-between flex-wrap gap-2 mb-2"> <div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <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 && ( {isSecret && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500"> <span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
secret secret
</span> </span>
)} )}
</div> </div>
{row.description && ( <code className="text-[11px] text-gray-400 font-mono mt-0.5">{row.key}</code>
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, Trash2, Loader2, RefreshCw, Zap, Check, ChevronDown, ChevronRight } from 'lucide-react';
const CATEGORY_META = {
'ai-tools': { label: 'AI инструменты', icon: '🤖', color: 'text-purple-400' },
'ai-dev': { label: 'AI разработка', icon: '💻', color: 'text-blue-400' },
'automation': { label: 'Автоматизация', icon: '⚙️', color: 'text-green-400' },
'cybersec': { label: 'Кибербезопасность', icon: '🔒', color: 'text-red-400' },
};
export default function AdminTopicBank() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState({}); // cat → expanded
const [msg, setMsg] = useState('');
const [gen, setGen] = useState({}); // cat → generating
// Форма добавления
const [addCat, setAddCat] = useState('ai-tools');
const [addText, setAddText] = useState('');
const [adding, setAdding] = useState(false);
const [showAdd, setShowAdd] = useState(false);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/blog-topics?includeUsed=true&limit=200').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, []);
async function addTopic() {
if (!addText.trim()) return;
setAdding(true);
const lines = addText.split('\n').map(l => l.trim()).filter(Boolean);
let added = 0;
for (const topic of lines) {
const res = await fetch('/api/admin/blog-topics', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: addCat, topic }),
}).then(r => r.json());
if (res.id) added++;
}
setMsg(`✓ Добавлено ${added} тем`);
setAddText(''); setShowAdd(false);
load();
setAdding(false);
setTimeout(() => setMsg(''), 2000);
}
async function deleteTopic(id) {
await fetch(`/api/admin/blog-topics/${id}`, { method: 'DELETE' });
load();
}
async function generate(category) {
setGen(g => ({ ...g, [category]: true }));
await fetch('/api/admin/blog-topics/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, count: 10 }),
});
setMsg(`⚡ Генерирую 10 тем для ${category} (~30с)`);
setTimeout(() => { load(); setMsg(''); }, 35000);
setTimeout(() => setGen(g => ({ ...g, [category]: false })), 35000);
}
const byCategory = {};
for (const t of data?.topics || []) {
if (!byCategory[t.category]) byCategory[t.category] = [];
byCategory[t.category].push(t);
}
const stats = Object.fromEntries((data?.stats || []).map(s => [s.category, s]));
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Банк тем блога</h2>
<p className="text-xs text-gray-400 mt-0.5">Темы для автогенерации статей на zeropost.ru</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-green-400">{msg}</span>}
<button onClick={() => setShowAdd(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-4 h-4" /> Добавить
</button>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Форма добавления */}
{showAdd && (
<div className="card p-4 border-accent/30 bg-accent/5 space-y-3">
<h3 className="font-medium text-sm">Добавить темы</h3>
<div className="flex gap-2">
<select value={addCat} onChange={e => setAddCat(e.target.value)} className="input text-sm py-1.5 w-48">
{Object.entries(CATEGORY_META).map(([k, v]) => (
<option key={k} value={k}>{v.icon} {v.label}</option>
))}
</select>
</div>
<textarea rows={4} value={addText} onChange={e => setAddText(e.target.value)}
placeholder={"Одна тема на строку:\nКак использовать Claude API в продакшене\nTop 10 AI инструментов для разработчиков"}
className="input w-full text-sm resize-none" autoFocus />
<div className="flex gap-2">
<button onClick={addTopic} disabled={adding || !addText.trim()}
className="btn-primary px-4 py-1.5 text-sm flex items-center gap-1.5">
{adding ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Добавить
</button>
<button onClick={() => { setShowAdd(false); setAddText(''); }}
className="btn-ghost px-3 py-1.5 text-sm">Отмена</button>
</div>
</div>
)}
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{/* Категории */}
{Object.entries(CATEGORY_META).map(([cat, cfg]) => {
const topics = byCategory[cat] || [];
const stat = stats[cat] || { total: 0, unused: 0 };
const isOpen = open[cat];
const unused = topics.filter(t => !t.is_published);
const used = topics.filter(t => t.is_published);
return (
<div key={cat} className="card overflow-hidden">
{/* Header */}
<button onClick={() => setOpen(o => ({ ...o, [cat]: !isOpen }))}
className="w-full flex items-center gap-3 px-5 py-4 hover:bg-surface2/30 transition-colors">
<span className="text-xl">{cfg.icon}</span>
<div className="flex-1 text-left">
<div className={`font-medium ${cfg.color}`}>{cfg.label}</div>
<div className="text-xs text-gray-500 mt-0.5">
{stat.unused} неиспользованных · {used.length} уже опубликованы · итого {stat.total}
</div>
</div>
{/* Прогресс-бар использования */}
<div className="w-24">
<div className="h-1.5 bg-surface2 rounded-full">
<div className={`h-1.5 rounded-full ${cfg.color.replace('text-','bg-')}`}
style={{ width: `${stat.total ? Math.round((used.length/stat.total)*100) : 0}%` }} />
</div>
<div className="text-xs text-gray-500 mt-1 text-right">
{stat.total ? Math.round((used.length/stat.total)*100) : 0}%
</div>
</div>
<button onClick={e => { e.stopPropagation(); generate(cat); }} disabled={gen[cat]}
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent shrink-0 ml-2">
{gen[cat] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Zap className="w-3 h-3" />}
+10 AI
</button>
{isOpen ? <ChevronDown className="w-4 h-4 text-gray-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />}
</button>
{/* Список тем */}
{isOpen && (
<div className="border-t border-border">
{/* Неиспользованные */}
{unused.length > 0 && (
<div className="px-5 py-3">
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
Не использованы ({unused.length})
</div>
<div className="space-y-1">
{unused.map(t => (
<div key={t.id} className="flex items-center gap-2 group py-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-accent/40 shrink-0" />
<span className="text-sm flex-1">{t.topic}</span>
<span className="text-xs text-gray-600">{t.source}</span>
<button onClick={() => deleteTopic(t.id)}
className="opacity-0 group-hover:opacity-100 btn-ghost p-1 text-gray-500 hover:text-red-400">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)}
{/* Использованные */}
{used.length > 0 && (
<div className="px-5 py-3 border-t border-border/50">
<div className="text-xs text-gray-600 uppercase tracking-wide mb-2">
Опубликованы ({used.length})
</div>
<div className="space-y-1 opacity-50">
{used.map(t => (
<div key={t.id} className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500/40 shrink-0" />
<span className="text-sm line-through">{t.topic}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}
+396
View File
@@ -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>
);
}