diff --git a/app/api/admin/zero/[...path]/route.js b/app/api/admin/zero/[...path]/route.js
new file mode 100644
index 0000000..a401b98
--- /dev/null
+++ b/app/api/admin/zero/[...path]/route.js
@@ -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;
diff --git a/components/AdminPanel.js b/components/AdminPanel.js
index dcd144b..68ccd26 100644
--- a/components/AdminPanel.js
+++ b/components/AdminPanel.js
@@ -1,6 +1,6 @@
'use client';
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, Mail } 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 AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
@@ -10,6 +10,7 @@ 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
@@ -25,6 +26,7 @@ const SECTIONS = [
{ 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: 'Коды для кредитов и скидок' },
@@ -81,6 +83,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'autogen' && }
{section === 'content' && }
{section === 'topicbank' && }
+ {section === 'zero' && }
{section === 'smtp' && } />}
{section === 'plans' && }
{section === 'promos' && }
diff --git a/components/admin/AdminZero.js b/components/admin/AdminZero.js
new file mode 100644
index 0000000..6521a4d
--- /dev/null
+++ b/components/admin/AdminZero.js
@@ -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 (
+
+ {/* HEADER */}
+
+
+
Заметки от Зеро
+
Короткие посты в @zeropostru от AI-персонажа · 13:00 МСК ежедневно
+
+
+ {msg &&
{msg}}
+
+
+
+
+
+ {/* GENERATE FORM */}
+ {genOpen && (
+
+
+ Новый черновик
+
+
+
+
+ setGenForm(f => ({ ...f, channel_id: e.target.value }))}
+ className="input text-sm py-1.5" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* FILTER TABS */}
+
+ 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 setFilter(key)}
+ label={`${meta.label} · ${cnt}`} colorClass={meta.color} />;
+ })}
+
+
+ {/* LIST */}
+ {loading && notes.length === 0 && (
+
+ )}
+ {!loading && filtered.length === 0 && (
+
+ Заметок в этом разделе нет. Жми «Сгенерировать» чтобы создать черновик.
+
+ )}
+
+
+ {filtered.map(note => (
+ 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)} />
+ ))}
+
+
+ );
+}
+
+// ──────────────────────────────────────────────────────────────
+// Sub-components
+// ──────────────────────────────────────────────────────────────
+function FilterTab({ active, onClick, label, colorClass }) {
+ return (
+
+ );
+}
+
+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 (
+
+ {/* HEADER */}
+
+
+
+
+ #{note.id}
+ {meta.label}
+ · {bucketLabel}
+ {note.pose && · поза: {note.pose}}
+ · {note.status === 'published'
+ ? `опубликовано ${mskTime(note.published_at)}`
+ : `на ${mskTime(note.scheduled_at)} МСК`}
+
+ {note.theme &&
тема: {note.theme}
}
+
+
+
+ {/* CONTENT or EDIT */}
+ {isEditing ? (
+
+ ) : (
+
{note.content}
+ )}
+
+ {/* ERROR */}
+ {note.error && (
+
+ Ошибка: {note.error}
+ {note.attempts > 0 && · попыток: {note.attempts}}
+
+ )}
+
+ {/* REGEN PICKER */}
+ {isRegen && (
+
+
Выбери ведро для перегенерации (или оставь пусто для anti-repeat):
+
+
+
+
+
+
+ )}
+
+ {/* ACTIONS */}
+ {!isEditing && !isRegen && (
+
+ {canApprove && (
+
+ )}
+ {canEdit && (
+
+ )}
+ {canRegen && (
+
+ )}
+ {canSkip && (
+
+ )}
+ {note.status === 'published' && note.channel_message_id && (
+
+ TG msg #{note.channel_message_id}
+
+ )}
+
+
+ {note.model || ''}
+ {note.tokens_in ? ` · ${note.tokens_in}→${note.tokens_out} tok` : ''}
+
+
+ )}
+
+ );
+}