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:
@@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user