diff --git a/app/admin/(protected)/autogen/page.js b/app/admin/(protected)/autogen/page.js
index 7803d18..23942de 100644
--- a/app/admin/(protected)/autogen/page.js
+++ b/app/admin/(protected)/autogen/page.js
@@ -1,5 +1,8 @@
import { requireAdminAuth } from '@/lib/adminAuth';
import AutogenPanel from '@/components/admin/AutogenPanel';
+import ZeroAutogenCard from '@/components/admin/ZeroAutogenCard';
+import Link from 'next/link';
+import { Coffee, ArrowRight } from 'lucide-react';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Автогенерация' };
@@ -18,11 +21,33 @@ async function engineCall(path) {
export default async function AutogenPage() {
await requireAdminAuth();
- const [status, queue, topics] = await Promise.all([
+ const [status, queue, topics, zeroConfig, zeroNotes] = await Promise.all([
engineCall('/api/autogen/status'),
engineCall('/api/autogen/queue'),
engineCall('/api/autogen/topics'),
+ engineCall('/api/admin/zero/config'),
+ engineCall('/api/admin/zero/notes?limit=5'),
]);
- return ;
+ return (
+
+
+
+ {/* Блок Зеро — отдельная карточка рядом с категориями статей */}
+
+
+
+
+ Заметки от Зеро
+
+
AI-персонаж в @zeropostru — отдельный пайплайн
+
+
+ Полный раздел
+
+
+
+
+
+ );
}
diff --git a/app/admin/(protected)/zero/page.js b/app/admin/(protected)/zero/page.js
new file mode 100644
index 0000000..f9ad21b
--- /dev/null
+++ b/app/admin/(protected)/zero/page.js
@@ -0,0 +1,10 @@
+import { requireAdminAuth } from '@/lib/adminAuth';
+import AdminZero from '@/components/admin/AdminZero';
+
+export const dynamic = 'force-dynamic';
+export const metadata = { title: 'Заметки от Зеро' };
+
+export default async function AdminZeroPage() {
+ await requireAdminAuth();
+ return ;
+}
diff --git a/app/admin/api/zero/[...path]/route.js b/app/admin/api/zero/[...path]/route.js
new file mode 100644
index 0000000..b84f376
--- /dev/null
+++ b/app/admin/api/zero/[...path]/route.js
@@ -0,0 +1,41 @@
+/**
+ * Catch-all proxy для /admin/api/zero/* → engine /api/admin/zero/*
+ * Auth: session cookie через checkAdminAuth().
+ */
+import { NextResponse } from 'next/server';
+import { checkAdminAuth } from '@/lib/adminAuth';
+
+const E = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
+const S = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
+
+async function proxy(req, { params }) {
+ if (!(await checkAdminAuth())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+ const resolved = await params;
+ const tail = (resolved?.path || []).join('/');
+ const qs = req.url.split('?')[1];
+ const url = `${E}/api/admin/zero${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`;
+
+ const headers = {
+ 'x-internal-secret': S,
+ 'x-user-id': '1', // engine requireAdmin требует is_admin=true; на проде у нас один админ
+ };
+
+ let body;
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
+ headers['Content-Type'] = 'application/json';
+ const raw = await req.text();
+ body = raw || undefined;
+ }
+
+ 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/app/page.js b/app/page.js
index 8dfcc10..cf7797d 100644
--- a/app/page.js
+++ b/app/page.js
@@ -6,12 +6,13 @@ import HeroImage from '@/components/HeroImage';
import Stats from '@/components/Stats';
import NowBlock from '@/components/NowBlock';
import NotesBlock from '@/components/NotesBlock';
+import ZeroBlock from '@/components/ZeroBlock';
import SeriesGrid from '@/components/SeriesGrid';
import CategoryRow from '@/components/CategoryRow';
import PopularBlock from '@/components/PopularBlock';
import RecentBlock from '@/components/RecentBlock';
import Reveal from '@/components/Reveal';
-import { getHomeData, listTags, getStats, getLive, listNotes, listSeries, listCategories } from '@/lib/engine';
+import { getHomeData, listTags, getStats, getLive, listNotes, listSeries, listCategories, listZeroNotes } from '@/lib/engine';
import { Sparkles, ArrowRight } from 'lucide-react';
export const dynamic = 'force-dynamic';
@@ -20,10 +21,10 @@ const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec'];
export default async function HomePage() {
let home = { hero: null, byCategory: {}, popular: [], recent: [] };
- let tags = [], stats = null, live = null, notes = [], series = [], categories = [];
+ let tags = [], stats = null, live = null, notes = [], series = [], categories = [], zeroNotes = [];
try {
- [home, tags, stats, live, notes, series, categories] = await Promise.all([
+ [home, tags, stats, live, notes, series, categories, zeroNotes] = await Promise.all([
getHomeData(),
listTags(),
getStats(),
@@ -31,6 +32,7 @@ export default async function HomePage() {
listNotes({ limit: 6 }),
listSeries(),
listCategories(),
+ listZeroNotes({ limit: 6 }),
]);
} catch (err) {
console.error('Home load failed:', err.message);
@@ -131,6 +133,15 @@ export default async function HomePage() {
)}
+ {/* ЗЕРО — короткие заметки AI-персонажа */}
+ {zeroNotes.length > 0 && (
+
+
+
+
+
+ )}
+
{/* КАТЕГОРИЙНЫЕ РЯДЫ */}
{CATEGORY_ORDER.map(cat => (
diff --git a/app/zero/page.js b/app/zero/page.js
new file mode 100644
index 0000000..2f87467
--- /dev/null
+++ b/app/zero/page.js
@@ -0,0 +1,75 @@
+import Header from '@/components/Header';
+import Footer from '@/components/Footer';
+import ZeroBlock from '@/components/ZeroBlock';
+import { listZeroNotes, getZeroCharacter } from '@/lib/engine';
+import { Coffee } from 'lucide-react';
+
+export const dynamic = 'force-dynamic';
+export const metadata = {
+ title: 'Заметки от Зеро',
+ description: 'Короткие посты от AI-персонажа Зеро — мысли программиста о работе, инструментах и забавных багах',
+};
+
+export default async function ZeroPage() {
+ const [notes, character] = await Promise.all([
+ listZeroNotes({ limit: 100 }),
+ getZeroCharacter(),
+ ]);
+
+ return (
+ <>
+
+
+
+
+ AI-персонаж
+
+
+ Заметки от Зеро
+
+
+ Короткие посты от первого лица в Telegram-канале{' '}
+ @zeropostru .
+ Программист с многолетним опытом, любит копаться под капотом, постоянно носится с кофе.
+
+
+ {character?.character?.bio && (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
Кто такой Зеро
+
+ {character.character.bio.map((line, i) => (
+ — {line}
+ ))}
+
+
+
+
+ )}
+
+
+ {notes.length > 0 ? (
+
+ ) : (
+
+
+
+
Зеро ещё не написал ни одной заметки. Скоро появится.
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/components/ZeroBlock.js b/components/ZeroBlock.js
new file mode 100644
index 0000000..f94efd5
--- /dev/null
+++ b/components/ZeroBlock.js
@@ -0,0 +1,72 @@
+import Link from 'next/link';
+import { Coffee, ArrowRight } from 'lucide-react';
+import { formatDate } from '@/lib/markdown';
+
+/**
+ * ZeroBlock — лента коротких заметок от AI-персонажа Зеро.
+ * Отображается на главной (compact) и на /zero (полный список).
+ */
+function ZeroCard({ note }) {
+ return (
+
+
+ {note.image_url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+
+
+ )}
+
+
Зеро
+
{formatDate(note.published_at)}
+
+
+
+ {note.content}
+
+ {note.channel_message_id && (
+
+ в Telegram
+
+ )}
+
+ );
+}
+
+export default function ZeroBlock({ notes, compact = false }) {
+ if (!notes || notes.length === 0) return null;
+ const items = compact ? notes.slice(0, 3) : notes;
+
+ return (
+
+
+
+
+
+ Заметки от Зеро
+
+ · короткие мысли AI-программиста
+
+ {compact && notes.length > 3 && (
+
+ Все
+
+ )}
+
+
+ {items.map(n => )}
+
+
+ );
+}
diff --git a/components/admin/AdminNav.js b/components/admin/AdminNav.js
index 7fcbae4..0dbda34 100644
--- a/components/admin/AdminNav.js
+++ b/components/admin/AdminNav.js
@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
-import { LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink, MessageCircle, Clock } from 'lucide-react';
+import { LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink, MessageCircle, Clock, Coffee } from 'lucide-react';
const NAV = [
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
@@ -10,6 +10,7 @@ const NAV = [
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
{ href: '/admin/notes', label: 'Заметки', icon: MessageCircle },
+ { href: '/admin/zero', label: 'Зеро', icon: Coffee },
{ href: '/admin/settings', label: 'Настройки', icon: Settings },
];
diff --git a/components/admin/AdminZero.js b/components/admin/AdminZero.js
new file mode 100644
index 0000000..69acdcf
--- /dev/null
+++ b/components/admin/AdminZero.js
@@ -0,0 +1,514 @@
+'use client';
+import { useState, useEffect, useCallback } from 'react';
+import {
+ Loader2, RefreshCw, Plus, Check, X, Edit3, Save, Zap, Send,
+ Coffee, Bug, Wrench, MessageCircle, Sparkles, Trash2, Settings,
+} from 'lucide-react';
+
+// ─── Метаданные UI ────────────────────────────────────────────────────────
+const STATUS_META = {
+ draft: { label: 'Черновик', dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950/30', border: 'border-amber-200 dark:border-amber-900' },
+ approved: { label: 'Одобрено', dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950/30', border: 'border-blue-200 dark:border-blue-900' },
+ sending: { label: 'Отправка…', dot: 'bg-cyan-500', text: 'text-cyan-700 dark:text-cyan-400', bg: 'bg-cyan-50 dark:bg-cyan-950/30', border: 'border-cyan-200 dark:border-cyan-900' },
+ published: { label: 'Опубликовано', dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400',bg: 'bg-emerald-50 dark:bg-emerald-950/30', border: 'border-emerald-200 dark:border-emerald-900' },
+ failed: { label: 'Ошибка', dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950/30', border: 'border-red-200 dark:border-red-900' },
+ skipped: { label: 'Пропущено', dot: 'bg-neutral-400', text: 'text-neutral-500', bg: 'bg-neutral-50 dark:bg-neutral-900', border: 'border-neutral-200 dark:border-neutral-800' },
+};
+
+const BUCKET_ICON = {
+ bug_story: Bug, tools: Wrench, coffee_thoughts: Coffee, ai_industry: Sparkles,
+};
+const bucketIcon = (k) => BUCKET_ICON[k] || MessageCircle;
+
+const mskTime = (iso) => {
+ if (!iso) return '—';
+ return new Date(iso).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
+};
+const wordCount = (s) => s ? s.trim().split(/\s+/).length : 0;
+
+// ─── Main ────────────────────────────────────────────────────────────────
+export default function AdminZero() {
+ const [notes, setNotes] = useState([]);
+ const [buckets, setBuckets] = useState([]);
+ const [config, setConfig] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('all');
+ const [toast, setToast] = useState(null);
+
+ // generate form
+ const [genOpen, setGenOpen] = useState(false);
+ const [genForm, setGenForm] = useState({ channel_id: 1, force_bucket: '', allow_today_dup: false });
+ const [generating, setGen] = useState(false);
+
+ // edit / regen
+ const [editId, setEditId] = useState(null);
+ const [editText, setEditText] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [regenId, setRegenId] = useState(null);
+ const [regenBucket, setRegenBucket] = useState('');
+
+ // config (settings panel)
+ const [cfgOpen, setCfgOpen] = useState(false);
+
+ const flash = (msg, type = 'success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); };
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const [n, b, c] = await Promise.all([
+ fetch('/admin/api/zero/notes?limit=100').then(r => r.json()),
+ fetch('/admin/api/zero/buckets').then(r => r.json()),
+ fetch('/admin/api/zero/config').then(r => r.json()),
+ ]);
+ setNotes(n.items || []);
+ setBuckets(b.buckets || []);
+ setConfig(c.config || null);
+ // Подставим первый канал из config в форму генерации (если задан)
+ if (c.config?.ZERO_NOTES_CHANNEL_IDS) {
+ const firstId = parseInt(String(c.config.ZERO_NOTES_CHANNEL_IDS).split(',')[0], 10);
+ if (firstId) setGenForm(f => ({ ...f, channel_id: firstId }));
+ }
+ } catch (e) { flash('Ошибка загрузки: ' + e.message, 'error'); }
+ setLoading(false);
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ // ─── Actions ─────────────────────────────────────────────────
+ async function generate() {
+ setGen(true);
+ try {
+ const r = await fetch('/admin/api/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, 'error'); }
+ setGen(false);
+ }
+
+ async function approve(id) {
+ const r = await fetch(`/admin/api/zero/notes/${id}/approve`, { method: 'POST' });
+ if (r.ok) { flash(`Заметка #${id} одобрена`); load(); }
+ else { const d = await r.json(); flash(d.error || 'fail', 'error'); }
+ }
+
+ async function skip(id) {
+ if (!confirm('Пропустить эту заметку? Она не будет опубликована.')) return;
+ const r = await fetch(`/admin/api/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(`/admin/api/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 || 'fail', 'error');
+ }
+
+ async function saveEdit(id) {
+ setSaving(true);
+ try {
+ const r = await fetch(`/admin/api/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 || 'fail', 'error'); }
+ } catch (e) { flash(e.message, 'error'); }
+ setSaving(false);
+ }
+
+ async function publishNow(id) {
+ // Одобряем (если ещё draft) и ставим scheduled_at = NOW() — runner подхватит в ближайшую минуту
+ if (!confirm('Опубликовать сейчас (одобрить и поставить scheduled_at = сейчас)?')) return;
+ const r1 = await fetch(`/admin/api/zero/notes/${id}/approve`, { method: 'POST' });
+ if (!r1.ok && r1.status !== 404) {
+ const d = await r1.json(); flash('approve: ' + (d.error || 'fail'), 'error'); return;
+ }
+ const r2 = await fetch(`/admin/api/zero/notes/${id}`, {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ scheduled_at: new Date().toISOString() }),
+ });
+ if (r2.ok) { flash(`Заметка #${id} уйдёт в TG в ближайшую минуту`); load(); }
+ else { const d = await r2.json(); flash(d.error || 'fail', 'error'); }
+ }
+
+ async function saveConfig(patch) {
+ const r = await fetch('/admin/api/zero/config', {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ const data = await r.json();
+ if (r.ok) { flash('Настройки сохранены'); load(); }
+ else flash(data.error || 'fail', 'error');
+ }
+
+ 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 (
+
+ {toast && (
+
{toast.msg}
+ )}
+
+ {/* HEADER */}
+
+
+
+ Заметки от Зеро
+
+
+ AI-персонаж в @zeropostru · короткие посты от первого лица
+ {config && (config._enabled
+ ? ● автогенерация активна
+ : ○ автогенерация выключена )}
+
+
+
+
setCfgOpen(v => !v)}
+ className="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors">
+ Настройки
+
+
setGenOpen(v => !v)}
+ className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium transition-colors">
+ Сгенерировать
+
+
+
+
+
+
+
+ {/* CONFIG PANEL */}
+ {cfgOpen && config && (
+
setCfgOpen(false)} />
+ )}
+
+ {/* GENERATE FORM */}
+ {genOpen && (
+
+
+ Новый черновик
+
+
+
+ channel_id
+ setGenForm(f => ({ ...f, channel_id: e.target.value }))}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" />
+
+
+ Ведро темы
+ setGenForm(f => ({ ...f, force_bucket: e.target.value }))}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
+ Случайное (anti-repeat)
+ {buckets.map(b => {b.label} )}
+
+
+
+
+ setGenForm(f => ({ ...f, allow_today_dup: e.target.checked }))}
+ className="accent-emerald-500 w-4 h-4" />
+ Разрешить второй черновик за день
+
+
+
+
+
+ {generating ? : }
+ {generating ? 'Генерация ~20-30 сек…' : 'Запустить'}
+
+ setGenOpen(false)}
+ className="px-4 py-2 rounded-lg text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
+ Отмена
+
+
+
+ )}
+
+ {/* FILTERS */}
+
+ setFilter('all')} label="Все" count={notes.length} />
+ {Object.entries(STATUS_META).map(([key, meta]) => {
+ const cnt = counts[key] || 0;
+ if (cnt === 0 && !['draft', 'approved'].includes(key)) return null;
+ return setFilter(key)} label={meta.label} count={cnt} dotClass={meta.dot} />;
+ })}
+
+
+ {/* LIST */}
+ {loading && notes.length === 0 && (
+
+ )}
+ {!loading && filtered.length === 0 && (
+
+ {notes.length === 0 ? 'Заметок ещё нет — жми «Сгенерировать»' : 'В этом разделе пусто'}
+
+ )}
+
+
+ {filtered.map(n => (
+ { setEditId(n.id); setEditText(n.content); }}
+ onCancelEdit={() => setEditId(null)}
+ onSaveEdit={() => saveEdit(n.id)}
+ onApprove={() => approve(n.id)}
+ onSkip={() => skip(n.id)}
+ onPublishNow={() => publishNow(n.id)}
+ isRegen={regenId === n.id} regenBucket={regenBucket} setRegenBucket={setRegenBucket}
+ onRegenStart={() => { setRegenId(n.id); setRegenBucket(''); }}
+ onRegenCancel={() => setRegenId(null)}
+ onRegenConfirm={() => regenerate(n.id)} />
+ ))}
+
+
+ );
+}
+
+// ─── Sub-components ───────────────────────────────────────────────────────
+function FilterTab({ active, onClick, label, count, dotClass }) {
+ return (
+
+ {dotClass && }
+ {label}
+ {count}
+
+ );
+}
+
+function ConfigPanel({ config, onSave, onClose }) {
+ const [form, setForm] = useState({
+ ZERO_NOTES_CHANNEL_IDS: config.ZERO_NOTES_CHANNEL_IDS || '',
+ ZERO_NOTES_GENERATE_HOUR: config.ZERO_NOTES_GENERATE_HOUR || '13',
+ ZERO_NOTES_APPROVE_HOUR: config.ZERO_NOTES_APPROVE_HOUR || '7',
+ ZERO_NOTES_PUBLISH_HOUR: config.ZERO_NOTES_PUBLISH_HOUR || '13',
+ ZERO_SITE_URL_BASE: config.ZERO_SITE_URL_BASE || '',
+ });
+ const enabled = !!(form.ZERO_NOTES_CHANNEL_IDS && form.ZERO_NOTES_CHANNEL_IDS.trim());
+
+ return (
+
+
+
Настройки автогенерации Зеро
+
+
+
+ {/* Toggle Вкл/Выкл */}
+
+
+
Автогенерация
+
+ {enabled ? `Канал id: ${form.ZERO_NOTES_CHANNEL_IDS}` : 'Выключено — scheduler ничего не делает'}
+
+
+
{
+ const next = enabled ? '' : '1';
+ setForm(f => ({ ...f, ZERO_NOTES_CHANNEL_IDS: next }));
+ onSave({ ZERO_NOTES_CHANNEL_IDS: next });
+ }}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
+ enabled ? 'bg-emerald-500 text-white border-emerald-500' : 'border-neutral-300 dark:border-neutral-600 text-neutral-500'
+ }`}>
+ {enabled ? '● Вкл' : '○ Выкл'}
+
+
+
+
+ setForm(f => ({ ...f, ZERO_NOTES_CHANNEL_IDS: v }))}
+ placeholder="1" />
+ setForm(f => ({ ...f, ZERO_NOTES_GENERATE_HOUR: v }))} type="number" min="0" max="23" />
+ setForm(f => ({ ...f, ZERO_NOTES_APPROVE_HOUR: v }))} type="number" min="0" max="23" />
+ setForm(f => ({ ...f, ZERO_NOTES_PUBLISH_HOUR: v }))} type="number" min="0" max="23" />
+
+
+
setForm(f => ({ ...f, ZERO_SITE_URL_BASE: v }))} placeholder="https://zeropost.ru/zero" full />
+
+
+ onSave(form)}
+ className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium">
+ Сохранить
+
+
+ Закрыть
+
+
+
+ Время в МСК. Генерация ≠ публикация: в час генерации создаётся черновик со scheduled_at на ближайший час публикации (по умолчанию на сутки вперёд).
+
+
+ );
+}
+
+function ConfigField({ label, value, onChange, type = 'text', placeholder, min, max, full }) {
+ return (
+
+ {label}
+ onChange(e.target.value)} placeholder={placeholder} min={min} max={max}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" />
+
+ );
+}
+
+function NoteCard({
+ note, buckets,
+ isEditing, editText, setEditText, saving, onStartEdit, onCancelEdit, onSaveEdit,
+ onApprove, onSkip, onPublishNow,
+ 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);
+ const canPubNow = ['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):
+
setRegenBucket(e.target.value)}
+ className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500">
+ Случайное (anti-repeat)
+ {buckets.map(b => {b.label} )}
+
+
+
+ Перегенерировать
+
+ Отмена
+
+
+ )}
+
+ {/* ACTIONS */}
+ {!isEditing && !isRegen && (
+
+ {canApprove && (
+
+ Одобрить
+
+ )}
+ {canPubNow && (
+
+ Опубликовать сейчас
+
+ )}
+ {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` : ''}
+
+
+ )}
+
+ );
+}
diff --git a/components/admin/ZeroAutogenCard.js b/components/admin/ZeroAutogenCard.js
new file mode 100644
index 0000000..391ff72
--- /dev/null
+++ b/components/admin/ZeroAutogenCard.js
@@ -0,0 +1,138 @@
+'use client';
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { Play, RefreshCw, Coffee, Clock, ArrowRight, Loader2 } from 'lucide-react';
+
+/**
+ * Карточка "Заметки от Зеро" в /admin/autogen.
+ * Минимум функций: переключатель вкл/выкл, выбор часа генерации/публикации,
+ * кнопка «Сгенерировать сейчас», превью последних заметок.
+ * Полное управление — на /admin/zero.
+ */
+export default function ZeroAutogenCard({ initialConfig, recentNotes = [] }) {
+ const router = useRouter();
+ const [config, setConfig] = useState(initialConfig || {});
+ const [busy, setBusy] = useState(false);
+ const [running, setRunning] = useState(false);
+ const [toast, setToast] = useState(null);
+
+ const enabled = !!(config.ZERO_NOTES_CHANNEL_IDS && String(config.ZERO_NOTES_CHANNEL_IDS).trim());
+ const channelId = parseInt(String(config.ZERO_NOTES_CHANNEL_IDS || '1').split(',')[0], 10) || 1;
+ const genHour = config.ZERO_NOTES_GENERATE_HOUR || '13';
+ const pubHour = config.ZERO_NOTES_PUBLISH_HOUR || '13';
+
+ const flash = (msg, type='success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); };
+
+ async function patchConfig(patch) {
+ setBusy(true);
+ try {
+ const r = await fetch('/admin/api/zero/config', {
+ method: 'PATCH', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(patch),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || 'fail');
+ setConfig(c => ({ ...c, ...patch, _enabled: !!(patch.ZERO_NOTES_CHANNEL_IDS ?? c.ZERO_NOTES_CHANNEL_IDS) }));
+ flash('Сохранено');
+ } catch (e) { flash(e.message, 'error'); }
+ setBusy(false);
+ }
+
+ async function toggle() {
+ await patchConfig({ ZERO_NOTES_CHANNEL_IDS: enabled ? '' : '1' });
+ }
+
+ async function generateNow() {
+ setRunning(true);
+ try {
+ const r = await fetch('/admin/api/zero/generate', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ channel_id: channelId, allow_today_dup: true }),
+ });
+ const d = await r.json();
+ if (!r.ok) throw new Error(d.error || 'fail');
+ flash(`Черновик #${d.note.id} создан · ${d.note.theme_bucket}`);
+ router.refresh();
+ } catch (e) { flash(e.message, 'error'); }
+ setRunning(false);
+ }
+
+ return (
+
+ {toast && (
+
{toast.msg}
+ )}
+
+
+
+
+
+
+
+
Зеро · @zeropostru
+
+ {recentNotes.length} заметок в системе
+
+
+
+
+ {enabled ? '● Вкл' : '○ Выкл'}
+
+
+
+ {/* Расписание */}
+
+
+
+ Час генерации МСК:
+ patchConfig({ ZERO_NOTES_GENERATE_HOUR: e.target.value })}
+ className="text-xs bg-white/60 dark:bg-black/20 border border-amber-300 dark:border-amber-800 rounded px-2 py-0.5 font-mono">
+ {Array.from({length: 24}, (_,i) => {String(i).padStart(2,'0')}:00 )}
+
+ → публикация в
+ patchConfig({ ZERO_NOTES_PUBLISH_HOUR: e.target.value })}
+ className="text-xs bg-white/60 dark:bg-black/20 border border-amber-300 dark:border-amber-800 rounded px-2 py-0.5 font-mono">
+ {Array.from({length: 24}, (_,i) => {String(i).padStart(2,'0')}:00 )}
+
+
+
+ Каждый день в {genHour}:00 МСК генерится черновик · auto-approve в {config.ZERO_NOTES_APPROVE_HOUR || '7'}:00 МСК · публикация в {pubHour}:00 МСК (на следующие сутки)
+
+
+
+
+ {running ? : }
+ {running ? 'Генерация ~20-30 сек…' : 'Сгенерировать сейчас'}
+
+
+ {/* Последние заметки превью */}
+ {recentNotes.length > 0 && (
+
+
Последние
+ {recentNotes.slice(0, 3).map(n => (
+
+ #{n.id}
+ {n.status}
+ {n.theme || n.content?.slice(0, 60)}
+
+ ))}
+
+ )}
+
+
+ Открыть полный раздел
+
+
+ );
+}
diff --git a/lib/engine.js b/lib/engine.js
index 96691ab..1bb0363 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -221,6 +221,17 @@ export async function adminRequeueArticle(articleId) {
return call(`/api/scheduled-posts/schedule-article/${articleId}`, { method: 'POST' });
}
+// ── Zero notes — публичный API для сайта (zeropost.ru/zero) ──────────────
+export async function listZeroNotes({ limit = 12, offset = 0 } = {}) {
+ try { return (await call(`/api/zero/notes?limit=${limit}&offset=${offset}`, { cache: 'no-store' })).items || []; }
+ catch { return []; }
+}
+
+export async function getZeroCharacter() {
+ try { return await call('/api/zero/character', { next: { revalidate: 3600 } }); }
+ catch { return null; }
+}
+
// Главная страница — собранный набор секций
export async function getHomeData() {
return call('/api/articles/home');