feat(zero): admin panel section + site /zero page + autogen card

Admin (/admin/zero):
  - new AdminZero with list, status filters, generate button (bucket + allow_dup)
  - per-note actions: approve, edit inline, regenerate (bucket pick), skip,
    'publish now' (approve + scheduled_at=now → runner picks up within 1m)
  - config panel: toggle on/off, generate/approve/publish hour MSK, site URL base
  - new pin 'Зеро' () in AdminNav

Site (zeropost.ru):
  - ZeroBlock — feed of last 3-6 Zero notes, rendered on home next to Серии
  - /zero — full Zero notes list page with character bio block (avatar + bullets)

Autogen integration:
  - ZeroAutogenCard on /admin/autogen — amber card with on/off, hour pickers,
    'generate now' and last-3 preview, link to full section

Plumbing:
  - lib/engine.js: listZeroNotes(), getZeroCharacter()
  - app/admin/api/zero/[...path]/route.js: catch-all proxy with cookie auth
This commit is contained in:
Aleksei Pavlov
2026-06-19 11:17:19 +03:00
parent cbcc8177f6
commit 8700b8fc69
10 changed files with 904 additions and 6 deletions
+27 -2
View File
@@ -1,5 +1,8 @@
import { requireAdminAuth } from '@/lib/adminAuth'; import { requireAdminAuth } from '@/lib/adminAuth';
import AutogenPanel from '@/components/admin/AutogenPanel'; 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 dynamic = 'force-dynamic';
export const metadata = { title: 'Автогенерация' }; export const metadata = { title: 'Автогенерация' };
@@ -18,11 +21,33 @@ async function engineCall(path) {
export default async function AutogenPage() { export default async function AutogenPage() {
await requireAdminAuth(); 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/status'),
engineCall('/api/autogen/queue'), engineCall('/api/autogen/queue'),
engineCall('/api/autogen/topics'), engineCall('/api/autogen/topics'),
engineCall('/api/admin/zero/config'),
engineCall('/api/admin/zero/notes?limit=5'),
]); ]);
return <AutogenPanel status={status || []} queue={queue || []} topics={topics || {}} />; return (
<div className="space-y-8">
<AutogenPanel status={status || []} queue={queue || []} topics={topics || {}} />
{/* Блок Зеро — отдельная карточка рядом с категориями статей */}
<div className="border-t border-neutral-200 dark:border-neutral-800 pt-8">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Coffee className="w-5 h-5 text-amber-500" /> Заметки от Зеро
</h2>
<p className="text-xs text-neutral-500 mt-0.5">AI-персонаж в @zeropostru отдельный пайплайн</p>
</div>
<Link href="/admin/zero" className="text-sm text-emerald-600 hover:underline inline-flex items-center gap-1">
Полный раздел <ArrowRight className="w-3 h-3" />
</Link>
</div>
<ZeroAutogenCard initialConfig={zeroConfig?.config || null} recentNotes={zeroNotes?.items || []} />
</div>
</div>
);
} }
+10
View File
@@ -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 <AdminZero />;
}
+41
View File
@@ -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;
+14 -3
View File
@@ -6,12 +6,13 @@ import HeroImage from '@/components/HeroImage';
import Stats from '@/components/Stats'; import Stats from '@/components/Stats';
import NowBlock from '@/components/NowBlock'; import NowBlock from '@/components/NowBlock';
import NotesBlock from '@/components/NotesBlock'; import NotesBlock from '@/components/NotesBlock';
import ZeroBlock from '@/components/ZeroBlock';
import SeriesGrid from '@/components/SeriesGrid'; import SeriesGrid from '@/components/SeriesGrid';
import CategoryRow from '@/components/CategoryRow'; import CategoryRow from '@/components/CategoryRow';
import PopularBlock from '@/components/PopularBlock'; import PopularBlock from '@/components/PopularBlock';
import RecentBlock from '@/components/RecentBlock'; import RecentBlock from '@/components/RecentBlock';
import Reveal from '@/components/Reveal'; 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'; import { Sparkles, ArrowRight } from 'lucide-react';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -20,10 +21,10 @@ const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec'];
export default async function HomePage() { export default async function HomePage() {
let home = { hero: null, byCategory: {}, popular: [], recent: [] }; 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 { try {
[home, tags, stats, live, notes, series, categories] = await Promise.all([ [home, tags, stats, live, notes, series, categories, zeroNotes] = await Promise.all([
getHomeData(), getHomeData(),
listTags(), listTags(),
getStats(), getStats(),
@@ -31,6 +32,7 @@ export default async function HomePage() {
listNotes({ limit: 6 }), listNotes({ limit: 6 }),
listSeries(), listSeries(),
listCategories(), listCategories(),
listZeroNotes({ limit: 6 }),
]); ]);
} catch (err) { } catch (err) {
console.error('Home load failed:', err.message); console.error('Home load failed:', err.message);
@@ -131,6 +133,15 @@ export default async function HomePage() {
</Reveal> </Reveal>
)} )}
{/* ЗЕРО — короткие заметки AI-персонажа */}
{zeroNotes.length > 0 && (
<Reveal>
<div className="reveal">
<ZeroBlock notes={zeroNotes} compact />
</div>
</Reveal>
)}
{/* КАТЕГОРИЙНЫЕ РЯДЫ */} {/* КАТЕГОРИЙНЫЕ РЯДЫ */}
{CATEGORY_ORDER.map(cat => ( {CATEGORY_ORDER.map(cat => (
<Reveal key={cat}> <Reveal key={cat}>
+75
View File
@@ -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 (
<>
<Header />
<main className="pt-10 pb-16">
<div className="container-wide mb-10">
<div
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-4"
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
>
<Coffee className="w-3.5 h-3.5" /> AI-персонаж
</div>
<h1 className="text-3xl sm:text-5xl font-bold ink mb-3 leading-tight">
Заметки от Зеро
</h1>
<p className="mute text-base sm:text-lg max-w-2xl mb-8">
Короткие посты от первого лица в Telegram-канале{' '}
<a href="https://t.me/zeropostru" target="_blank" rel="noreferrer" className="accent hover:underline">@zeropostru</a>.
Программист с многолетним опытом, любит копаться под капотом, постоянно носится с кофе.
</p>
{character?.character?.bio && (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 p-5 sm:p-6 max-w-3xl">
<div className="flex items-start gap-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/uploads/zero-coffee.webp"
alt="Зеро"
className="w-16 h-16 rounded-full object-cover bg-amber-100 dark:bg-amber-950/40 ring-2 ring-amber-300 dark:ring-amber-800 shrink-0"
/>
<div className="min-w-0">
<div className="text-sm font-semibold ink mb-2">Кто такой Зеро</div>
<ul className="space-y-1 text-sm mute">
{character.character.bio.map((line, i) => (
<li key={i}> {line}</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
{notes.length > 0 ? (
<ZeroBlock notes={notes} />
) : (
<div className="container-wide">
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 p-10 text-center">
<Coffee className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<p className="mute text-sm">Зеро ещё не написал ни одной заметки. Скоро появится.</p>
</div>
</div>
)}
</main>
<Footer />
</>
);
}
+72
View File
@@ -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 (
<article className="article-card p-5 sm:p-6 h-full flex flex-col">
<div className="flex items-center gap-3 mb-3">
{note.image_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={note.image_url}
alt="Зеро"
className="w-9 h-9 rounded-full object-cover bg-amber-100 dark:bg-amber-950/40 ring-2 ring-amber-200 dark:ring-amber-900 shrink-0"
/>
) : (
<div className="w-9 h-9 rounded-full bg-amber-100 dark:bg-amber-950/40 ring-2 ring-amber-200 dark:ring-amber-900 flex items-center justify-center shrink-0">
<Coffee className="w-4 h-4 text-amber-600" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-semibold ink leading-tight">Зеро</div>
<div className="text-xs mute">{formatDate(note.published_at)}</div>
</div>
</div>
<p className="mute text-sm leading-relaxed mb-4 flex-1 whitespace-pre-line line-clamp-7">
{note.content}
</p>
{note.channel_message_id && (
<Link
href={`https://t.me/zeropostru/${note.channel_message_id}`}
target="_blank"
rel="noreferrer"
className="text-xs accent hover:underline inline-flex items-center gap-1"
>
в Telegram <ArrowRight className="w-3 h-3" />
</Link>
)}
</article>
);
}
export default function ZeroBlock({ notes, compact = false }) {
if (!notes || notes.length === 0) return null;
const items = compact ? notes.slice(0, 3) : notes;
return (
<section className="container-wide pb-12">
<div className="flex items-center justify-between mb-4 sm:mb-5">
<div className="flex items-center gap-2">
<Coffee className="w-4 h-4 mute" />
<h2 className="text-xs sm:text-sm font-medium uppercase tracking-widest mute">
Заметки от Зеро
</h2>
<span className="text-xs mute opacity-60">· короткие мысли AI-программиста</span>
</div>
{compact && notes.length > 3 && (
<Link href="/zero" className="text-xs mute hover:ink transition-colors inline-flex items-center gap-1">
Все <ArrowRight className="w-3 h-3" />
</Link>
)}
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{items.map(n => <ZeroCard key={n.id} note={n} />)}
</div>
</section>
);
}
+2 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation'; 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 = [ const NAV = [
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true }, { href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
@@ -10,6 +10,7 @@ const NAV = [
{ href: '/admin/channels', label: 'Каналы', icon: Radio }, { href: '/admin/channels', label: 'Каналы', icon: Radio },
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap }, { href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
{ href: '/admin/notes', label: 'Заметки', icon: MessageCircle }, { href: '/admin/notes', label: 'Заметки', icon: MessageCircle },
{ href: '/admin/zero', label: 'Зеро', icon: Coffee },
{ href: '/admin/settings', label: 'Настройки', icon: Settings }, { href: '/admin/settings', label: 'Настройки', icon: Settings },
]; ];
+514
View File
@@ -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 (
<div className="space-y-6">
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
}`}>{toast.msg}</div>
)}
{/* HEADER */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Coffee className="w-6 h-6 text-emerald-500" /> Заметки от Зеро
</h1>
<p className="text-sm text-neutral-500 mt-1">
AI-персонаж в <a href="https://t.me/zeropostru" target="_blank" rel="noreferrer" className="text-emerald-600 hover:underline">@zeropostru</a> · короткие посты от первого лица
{config && (config._enabled
? <span className="ml-2 text-xs text-emerald-600"> автогенерация активна</span>
: <span className="ml-2 text-xs text-neutral-400"> автогенерация выключена</span>)}
</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => 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">
<Settings className="w-4 h-4" /> Настройки
</button>
<button onClick={() => 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">
<Plus className="w-4 h-4" /> Сгенерировать
</button>
<button onClick={load}
className="p-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* CONFIG PANEL */}
{cfgOpen && config && (
<ConfigPanel config={config} onSave={saveConfig} onClose={() => setCfgOpen(false)} />
)}
{/* GENERATE FORM */}
{genOpen && (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 p-5 space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2 text-neutral-900 dark:text-neutral-100">
<Zap className="w-4 h-4 text-emerald-500" /> Новый черновик
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">channel_id</label>
<input type="number" value={genForm.channel_id}
onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">Ведро темы</label>
<select value={genForm.force_bucket}
onChange={e => 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">
<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 text-neutral-600 dark:text-neutral-400">
<input type="checkbox" checked={genForm.allow_today_dup}
onChange={e => setGenForm(f => ({ ...f, allow_today_dup: e.target.checked }))}
className="accent-emerald-500 w-4 h-4" />
Разрешить второй черновик за день
</label>
</div>
</div>
<div className="flex gap-2">
<button onClick={generate} disabled={generating}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{generating ? 'Генерация ~20-30 сек…' : 'Запустить'}
</button>
<button onClick={() => 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">
Отмена
</button>
</div>
</div>
)}
{/* FILTERS */}
<div className="flex flex-wrap gap-1.5 text-sm">
<FilterTab active={filter === 'all'} onClick={() => 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 <FilterTab key={key} active={filter === key} onClick={() => setFilter(key)} label={meta.label} count={cnt} dotClass={meta.dot} />;
})}
</div>
{/* LIST */}
{loading && notes.length === 0 && (
<div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-emerald-500" /></div>
)}
{!loading && filtered.length === 0 && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 p-10 text-center text-sm text-neutral-400">
{notes.length === 0 ? 'Заметок ещё нет — жми «Сгенерировать»' : 'В этом разделе пусто'}
</div>
)}
<div className="space-y-3">
{filtered.map(n => (
<NoteCard key={n.id} note={n} buckets={buckets}
isEditing={editId === n.id} editText={editText} setEditText={setEditText} saving={saving}
onStartEdit={() => { 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)} />
))}
</div>
</div>
);
}
// ─── Sub-components ───────────────────────────────────────────────────────
function FilterTab({ active, onClick, label, count, dotClass }) {
return (
<button onClick={onClick}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
active
? 'border-emerald-500 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/40'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-500 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}>
{dotClass && <span className={`w-1.5 h-1.5 rounded-full ${dotClass}`} />}
{label}
<span className="text-xs opacity-60">{count}</span>
</button>
);
}
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 (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm text-neutral-900 dark:text-neutral-100">Настройки автогенерации Зеро</h3>
<button onClick={onClose} className="p-1 rounded text-neutral-400 hover:text-neutral-600"><X className="w-4 h-4" /></button>
</div>
{/* Toggle Вкл/Выкл */}
<div className="flex items-center justify-between p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50">
<div>
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">Автогенерация</div>
<div className="text-xs text-neutral-500 mt-0.5">
{enabled ? `Канал id: ${form.ZERO_NOTES_CHANNEL_IDS}` : 'Выключено — scheduler ничего не делает'}
</div>
</div>
<button onClick={() => {
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 ? '● Вкл' : '○ Выкл'}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<ConfigField label="Каналы (csv id)" value={form.ZERO_NOTES_CHANNEL_IDS}
onChange={v => setForm(f => ({ ...f, ZERO_NOTES_CHANNEL_IDS: v }))}
placeholder="1" />
<ConfigField label="Час генерации МСК" value={form.ZERO_NOTES_GENERATE_HOUR}
onChange={v => setForm(f => ({ ...f, ZERO_NOTES_GENERATE_HOUR: v }))} type="number" min="0" max="23" />
<ConfigField label="Час auto-approve" value={form.ZERO_NOTES_APPROVE_HOUR}
onChange={v => setForm(f => ({ ...f, ZERO_NOTES_APPROVE_HOUR: v }))} type="number" min="0" max="23" />
<ConfigField label="Час публикации МСК" value={form.ZERO_NOTES_PUBLISH_HOUR}
onChange={v => setForm(f => ({ ...f, ZERO_NOTES_PUBLISH_HOUR: v }))} type="number" min="0" max="23" />
</div>
<ConfigField label="URL базы для inline-кнопки «Открыть на сайте»" value={form.ZERO_SITE_URL_BASE}
onChange={v => setForm(f => ({ ...f, ZERO_SITE_URL_BASE: v }))} placeholder="https://zeropost.ru/zero" full />
<div className="flex gap-2">
<button onClick={() => 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">
<Save className="w-4 h-4" /> Сохранить
</button>
<button onClick={onClose}
className="px-4 py-2 rounded-lg text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
Закрыть
</button>
</div>
<p className="text-xs text-neutral-400">
Время в МСК. Генерация публикация: в час генерации создаётся черновик со scheduled_at на ближайший час публикации (по умолчанию на сутки вперёд).
</p>
</div>
);
}
function ConfigField({ label, value, onChange, type = 'text', placeholder, min, max, full }) {
return (
<div className={full ? 'col-span-full' : ''}>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">{label}</label>
<input type={type} value={value} onChange={e => 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" />
</div>
);
}
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 (
<div className={`rounded-xl border ${meta.border} ${meta.bg} p-5 transition-colors`}>
{/* HEADER */}
<div className="flex items-start gap-3 mb-3">
<Icon className="w-5 h-5 text-emerald-500 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-neutral-400">#{note.id}</span>
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded font-medium ${meta.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${meta.dot}`} /> {meta.label}
</span>
<span className="text-neutral-500">· {bucketLabel}</span>
{note.pose && <span className="text-neutral-400">· поза: {note.pose}</span>}
<span className="text-neutral-500">
· {note.status === 'published'
? `опубликовано ${mskTime(note.published_at)}`
: `на ${mskTime(note.scheduled_at)} МСК`}
</span>
</div>
{note.theme && <div className="text-xs text-neutral-500 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="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-emerald-500 font-mono" />
<div className="flex items-center gap-2 text-xs text-neutral-500">
<span>{wordCount(editText)} слов · {editText.length} символов</span>
<div className="flex-1" />
<button onClick={onCancelEdit} className="flex items-center gap-1 px-3 py-1 rounded text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
<X className="w-3 h-3" /> Отмена
</button>
<button onClick={onSaveEdit} disabled={saving}
className="flex items-center gap-1 px-3 py-1 rounded bg-emerald-500 hover:bg-emerald-600 text-white">
{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 text-neutral-800 dark:text-neutral-200">{note.content}</div>
)}
{/* ERROR */}
{note.error && (
<div className="mt-3 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded p-2">
Ошибка: {note.error}
{note.attempts > 0 && <span className="text-neutral-500"> · попыток: {note.attempts}</span>}
</div>
)}
{/* REGEN PICKER */}
{isRegen && (
<div className="mt-3 p-3 border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg space-y-2">
<div className="text-xs text-neutral-600 dark:text-neutral-400">Выбери ведро (или оставь пусто для anti-repeat):</div>
<select value={regenBucket} onChange={e => 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">
<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="flex items-center gap-1 px-3 py-1 rounded bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium">
<Zap className="w-3 h-3" /> Перегенерировать
</button>
<button onClick={onRegenCancel} className="px-3 py-1 rounded text-xs text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">Отмена</button>
</div>
</div>
)}
{/* ACTIONS */}
{!isEditing && !isRegen && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
{canApprove && (
<button onClick={onApprove} className="flex items-center gap-1 px-3 py-1.5 rounded bg-emerald-500 hover:bg-emerald-600 text-white font-medium">
<Check className="w-3 h-3" /> Одобрить
</button>
)}
{canPubNow && (
<button onClick={onPublishNow} className="flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-emerald-700 dark:text-emerald-400">
<Send className="w-3 h-3" /> Опубликовать сейчас
</button>
)}
{canEdit && (
<button onClick={onStartEdit} className="flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<Edit3 className="w-3 h-3" /> Редактировать
</button>
)}
{canRegen && (
<button onClick={onRegenStart} className="flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<RefreshCw className="w-3 h-3" /> Перегенерировать
</button>
)}
{canSkip && (
<button onClick={onSkip} className="flex items-center gap-1 px-3 py-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30">
<Trash2 className="w-3 h-3" /> Пропустить
</button>
)}
{note.status === 'published' && note.channel_message_id && (
<span className="text-neutral-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-neutral-400 font-mono">
{note.model || ''}
{note.tokens_in ? ` · ${note.tokens_in}${note.tokens_out} tok` : ''}
</span>
</div>
)}
</div>
);
}
+138
View File
@@ -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 (
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-950/30 p-5 relative">
{toast && (
<div className={`absolute top-3 right-3 z-10 px-3 py-2 rounded-lg text-xs font-medium shadow ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
}`}>{toast.msg}</div>
)}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-950/60 ring-1 ring-amber-300 dark:ring-amber-800 flex items-center justify-center shrink-0">
<Coffee className="w-5 h-5 text-amber-600" />
</div>
<div>
<div className="font-semibold text-sm text-amber-900 dark:text-amber-200">Зеро · @zeropostru</div>
<div className="text-xs text-amber-700/80 dark:text-amber-400/70">
{recentNotes.length} заметок в системе
</div>
</div>
</div>
<button onClick={toggle} disabled={busy}
className={`text-xs px-3 py-1 rounded-full font-medium border transition-colors ${
enabled
? 'bg-white dark:bg-neutral-900 border-amber-400 text-amber-700 dark:text-amber-300'
: 'border-amber-300 text-amber-700/60 dark:text-amber-500/60'
}`}>
{enabled ? '● Вкл' : '○ Выкл'}
</button>
</div>
{/* Расписание */}
<div className="space-y-2 mb-4 text-amber-900 dark:text-amber-200">
<div className="flex items-center gap-3 flex-wrap">
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
<span className="text-xs opacity-70">Час генерации МСК:</span>
<select value={genHour} disabled={busy}
onChange={e => 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) => <option key={i} value={i}>{String(i).padStart(2,'0')}:00</option>)}
</select>
<span className="text-xs opacity-50"> публикация в</span>
<select value={pubHour} disabled={busy}
onChange={e => 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) => <option key={i} value={i}>{String(i).padStart(2,'0')}:00</option>)}
</select>
</div>
<div className="text-xs opacity-60 pl-6">
Каждый день в {genHour}:00 МСК генерится черновик · auto-approve в {config.ZERO_NOTES_APPROVE_HOUR || '7'}:00 МСК · публикация в {pubHour}:00 МСК (на следующие сутки)
</div>
</div>
<button onClick={generateNow} disabled={running}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-white/70 dark:bg-black/20 hover:bg-white dark:hover:bg-black/30 disabled:opacity-50 text-sm font-medium transition-colors border border-amber-300 dark:border-amber-800 text-amber-800 dark:text-amber-200 mb-4">
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{running ? 'Генерация ~20-30 сек…' : 'Сгенерировать сейчас'}
</button>
{/* Последние заметки превью */}
{recentNotes.length > 0 && (
<div className="space-y-1.5 mb-3">
<div className="text-xs font-medium text-amber-900/70 dark:text-amber-300/70 uppercase tracking-wide">Последние</div>
{recentNotes.slice(0, 3).map(n => (
<div key={n.id} className="text-xs text-amber-900/80 dark:text-amber-200/80 flex items-start gap-2 py-1">
<span className="font-mono opacity-60 shrink-0">#{n.id}</span>
<span className="opacity-60 shrink-0">{n.status}</span>
<span className="truncate flex-1">{n.theme || n.content?.slice(0, 60)}</span>
</div>
))}
</div>
)}
<Link href="/admin/zero" className="text-xs text-amber-800 dark:text-amber-300 hover:underline inline-flex items-center gap-1">
Открыть полный раздел <ArrowRight className="w-3 h-3" />
</Link>
</div>
);
}
+11
View File
@@ -221,6 +221,17 @@ export async function adminRequeueArticle(articleId) {
return call(`/api/scheduled-posts/schedule-article/${articleId}`, { method: 'POST' }); 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() { export async function getHomeData() {
return call('/api/articles/home'); return call('/api/articles/home');