Compare commits

..

4 Commits

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

Files:
  app/api/admin/zero/[...path]/route.js  catch-all proxy → engine
  components/admin/AdminZero.js          main component
  components/AdminPanel.js               +section in sidebar
2026-06-19 10:53:00 +03:00
Alexey Pavlov 68fb51fc0a fix: system settings — show description as title, key as small monospace hint 2026-06-15 10:39:03 +03:00
Alexey Pavlov 33c11049f1 merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2 2026-06-15 10:28:42 +03:00
Alexey Pavlov 5be51d88f7 feat: channel history page — published posts with search 2026-06-15 10:28:07 +03:00
7 changed files with 628 additions and 6 deletions
+46
View File
@@ -0,0 +1,46 @@
/**
* Catch-all proxy для /api/admin/zero/* → engine /api/admin/zero/*
* Принимает любой метод и любой путь. Auth: session cookie → user.isAdmin.
*/
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
async function proxy(req, { params }) {
const user = await requireUser();
if (!user?.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const tail = (params?.path || []).join('/');
const qs = req.url.split('?')[1];
const url = `${ENGINE_URL}/api/admin/zero${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`;
const headers = {
'x-internal-secret': ENGINE_SECRET,
'x-user-id': String(user.id),
};
let body;
if (req.method !== 'GET' && req.method !== 'HEAD') {
const ct = req.headers.get('content-type') || '';
if (ct.includes('application/json')) {
headers['Content-Type'] = 'application/json';
const raw = await req.text();
body = raw || undefined;
} else {
body = await req.text();
}
}
const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' });
const data = await res.json().catch(() => ({ error: 'invalid engine response' }));
return NextResponse.json(data, { status: res.status });
}
export const GET = proxy;
export const POST = proxy;
export const PATCH = proxy;
export const PUT = proxy;
export const DELETE = proxy;
+28
View File
@@ -0,0 +1,28 @@
import { notFound, redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import ChannelHistory from '@/components/ChannelHistory';
export default async function ChannelHistoryPage({ params }) {
const user = await requireUser();
if (!user) redirect('/login');
const { id } = await params;
let channel, posts;
try {
channel = await engine.getChannel(user.id, id);
posts = await engine.listUserPosts(user.id, { channel_id: id, status: 'published', limit: 100 });
} catch {
notFound();
}
if (!channel) notFound();
return (
<>
<Header user={user} />
<ChannelHistory channel={channel} posts={posts || []} />
</>
);
}
+4 -1
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react'; import { Coffee, Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import AdminBilling from './admin/AdminBilling'; import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers'; import AdminUsers from './admin/AdminUsers';
@@ -10,6 +10,7 @@ import AdminLogs from './admin/AdminLogs';
import AdminAutogen from './admin/AdminAutogen'; import AdminAutogen from './admin/AdminAutogen';
import AdminContent from './admin/AdminContent'; import AdminContent from './admin/AdminContent';
import AdminTopicBank from './admin/AdminTopicBank'; import AdminTopicBank from './admin/AdminTopicBank';
import AdminZero from './admin/AdminZero';
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Sidebar navigation // Sidebar navigation
@@ -25,6 +26,7 @@ const SECTIONS = [
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' }, { id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' }, { id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
{ id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' }, { id: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для zeropost.ru' },
{ id: 'zero', label: 'Заметки Зеро', icon: Coffee, desc: 'AI-персонаж в @zeropostru' },
{ id: 'smtp', label: 'Email / SMTP', icon: Mail, desc: 'Уведомления пользователям' }, { id: 'smtp', label: 'Email / SMTP', icon: Mail, desc: 'Уведомления пользователям' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' }, { id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' }, { id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
@@ -81,6 +83,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'autogen' && <AdminAutogen />} {section === 'autogen' && <AdminAutogen />}
{section === 'content' && <AdminContent />} {section === 'content' && <AdminContent />}
{section === 'topicbank' && <AdminTopicBank />} {section === 'topicbank' && <AdminTopicBank />}
{section === 'zero' && <AdminZero />}
{section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />} {section === 'smtp' && <SettingsSection categories={['smtp']} extraActions={<SmtpTestButton />} />}
{section === 'plans' && <PlansSection />} {section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />} {section === 'promos' && <AdminPromos />}
+144
View File
@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Clock, Image as ImageIcon, Copy, Check, Search } from 'lucide-react';
function timeAgo(dateStr) {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч назад`;
const d = Math.floor(h / 24);
if (d < 30) return `${d} дн назад`;
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
}
function PostCard({ p }) {
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
const preview = p.content?.slice(0, 200) || '';
const isLong = (p.content?.length || 0) > 200;
function copy() {
navigator.clipboard.writeText(p.content || '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex flex-col gap-3">
{/* Шапка */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-gray-400">
<Clock size={12} />
<span>{timeAgo(p.published_at || p.updated_at || p.created_at)}</span>
</div>
<button
onClick={copy}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
{copied ? <Check size={13} className="text-green-500" /> : <Copy size={13} />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
{/* Картинка */}
{p.image_url && (
<img
src={p.image_url}
alt=""
className="w-full rounded-lg object-cover max-h-48"
/>
)}
{!p.image_url && (
<div className="w-full h-10 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center gap-2 px-3">
<ImageIcon size={14} className="text-gray-300" />
<span className="text-xs text-gray-300">Без изображения</span>
</div>
)}
{/* Текст */}
<div className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap leading-relaxed">
{expanded ? p.content : preview}
{isLong && !expanded && <span className="text-gray-400"></span>}
</div>
{isLong && (
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-indigo-500 hover:text-indigo-700 text-left"
>
{expanded ? 'Свернуть' : 'Показать полностью'}
</button>
)}
</div>
);
}
export default function ChannelHistory({ channel, posts }) {
const [query, setQuery] = useState('');
const filtered = query.trim()
? posts.filter(p => p.content?.toLowerCase().includes(query.toLowerCase()))
: posts;
return (
<main className="max-w-2xl mx-auto px-4 py-8">
{/* Навигация */}
<div className="flex items-center gap-3 mb-6">
<Link
href={`/channels/${channel.id}`}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
<ArrowLeft size={16} />
{channel.name}
</Link>
<span className="text-gray-300 dark:text-gray-700">/</span>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">История публикаций</span>
</div>
{/* Статистика + поиск */}
<div className="flex items-center justify-between gap-4 mb-6">
<p className="text-sm text-gray-500">
{posts.length === 0
? 'Публикаций пока нет'
: `${posts.length} ${posts.length === 1 ? 'публикация' : posts.length < 5 ? 'публикации' : 'публикаций'}`}
</p>
{posts.length > 0 && (
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Поиск по тексту"
value={query}
onChange={e => setQuery(e.target.value)}
className="pl-8 pr-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-300 w-48"
/>
</div>
)}
</div>
{/* Список постов */}
{filtered.length === 0 && query ? (
<p className="text-center text-sm text-gray-400 py-12">Ничего не найдено</p>
) : filtered.length === 0 ? (
<div className="text-center py-16">
<Clock size={32} className="mx-auto text-gray-200 dark:text-gray-700 mb-3" />
<p className="text-sm text-gray-400">Опубликованных постов пока нет</p>
<Link
href={`/channels/${channel.id}`}
className="mt-4 inline-block text-sm text-indigo-500 hover:text-indigo-700"
>
Создать первый пост
</Link>
</div>
) : (
<div className="flex flex-col gap-4">
{filtered.map(p => <PostCard key={p.id} p={p} />)}
</div>
)}
</main>
);
}
+6 -1
View File
@@ -4,7 +4,8 @@ import Link from 'next/link';
import { import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, Send, Clock, Search, Camera, ExternalLink, Link2 MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
Search, Camera, ExternalLink, Link2
} from 'lucide-react'; } from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal'; import PhotoSearchModal from './PhotoSearchModal';
import PostPreview from './PostPreview'; import PostPreview from './PostPreview';
@@ -351,6 +352,10 @@ export default function ChannelView({ channel }) {
</div> </div>
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>} {channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
</div> </div>
<Link href={`/channels/${channel.id}/history`} className="btn-ghost text-sm">
<History className="w-4 h-4" />
История
</Link>
<Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm"> <Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
<Settings className="w-4 h-4" /> <Settings className="w-4 h-4" />
Настройки Настройки
+4 -4
View File
@@ -290,16 +290,16 @@ function SettingRow({ row, onSaved }) {
<div className="flex items-start justify-between flex-wrap gap-2 mb-2"> <div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-sm font-mono">{row.key}</code> <span className="text-sm font-medium text-gray-800 dark:text-gray-200">
{row.description || row.key}
</span>
{isSecret && ( {isSecret && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500"> <span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
secret secret
</span> </span>
)} )}
</div> </div>
{row.description && ( <code className="text-[11px] text-gray-400 font-mono mt-0.5">{row.key}</code>
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+396
View File
@@ -0,0 +1,396 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Loader2, RefreshCw, Plus, Check, X, Edit3, Save, Zap, Send,
Coffee, Bug, Wrench, MessageCircle, Sparkles, ChevronDown, Trash2,
} from 'lucide-react';
// ──────────────────────────────────────────────────────────────
// Метаданные для UI
// ──────────────────────────────────────────────────────────────
const STATUS_META = {
draft: { label: 'Черновик', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30' },
approved: { label: 'Одобрено', color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' },
sending: { label: 'Отправка…', color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/30' },
published: { label: 'Опубликовано', color: 'text-emerald-400',bg: 'bg-emerald-500/10',border: 'border-emerald-500/30' },
failed: { label: 'Ошибка', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30' },
skipped: { label: 'Пропущено', color: 'text-gray-500', bg: 'bg-surface2', border: 'border-border' },
};
const BUCKET_ICON = {
bug_story: Bug,
tools: Wrench,
coffee_thoughts: Coffee,
ai_industry: Sparkles,
};
function bucketIcon(key) { return BUCKET_ICON[key] || MessageCircle; }
function mskTime(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function wordCount(s) { return s ? s.trim().split(/\s+/).length : 0; }
// ──────────────────────────────────────────────────────────────
// Main component
// ──────────────────────────────────────────────────────────────
export default function AdminZero() {
const [notes, setNotes] = useState([]);
const [buckets, setBuckets] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const [msg, setMsg] = useState('');
// Генерация
const [genOpen, setGenOpen] = useState(false);
const [genForm, setGenForm] = useState({ channel_id: 1, force_bucket: '', allow_today_dup: false });
const [generating, setGen] = useState(false);
// Edit
const [editId, setEditId] = useState(null);
const [editText, setEditText] = useState('');
const [saving, setSaving] = useState(false);
// Regenerate
const [regenId, setRegenId] = useState(null);
const [regenBucket, setRegenBucket] = useState('');
// ────── загрузка ──────
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fetch('/api/admin/zero/notes?limit=100').then(r => r.json());
setNotes(r.items || []);
} catch (e) {
setMsg('Ошибка загрузки: ' + e.message);
}
setLoading(false);
}, []);
useEffect(() => {
load();
fetch('/api/admin/zero/buckets').then(r => r.json()).then(r => setBuckets(r.buckets || []));
}, [load]);
const flash = (text) => {
setMsg(text);
setTimeout(() => setMsg(''), 3000);
};
// ────── actions ──────
async function generate() {
setGen(true);
try {
const r = await fetch('/api/admin/zero/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: Number(genForm.channel_id),
force_bucket: genForm.force_bucket || undefined,
allow_today_dup: genForm.allow_today_dup,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка генерации');
flash(`✓ Черновик #${data.note.id} создан · ведро ${data.note.theme_bucket}`);
setGenOpen(false);
await load();
} catch (e) {
flash('✗ ' + e.message);
}
setGen(false);
}
async function approve(id) {
const r = await fetch(`/api/admin/zero/notes/${id}/approve`, { method: 'POST' });
if (r.ok) { flash(`✓ Заметка #${id} одобрена`); load(); }
else { const d = await r.json(); flash('✗ ' + (d.error || 'fail')); }
}
async function skip(id) {
if (!confirm('Пропустить эту заметку? Она не будет опубликована.')) return;
const r = await fetch(`/api/admin/zero/notes/${id}/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: 'skipped by admin via UI' }),
});
if (r.ok) { flash(`Заметка #${id} пропущена`); load(); }
}
async function regenerate(id) {
const r = await fetch(`/api/admin/zero/notes/${id}/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force_bucket: regenBucket || undefined }),
});
const data = await r.json();
if (r.ok) { flash(`✓ Регенерация #${id} → #${data.note?.id}`); setRegenId(null); setRegenBucket(''); load(); }
else flash('✗ ' + data.error);
}
function startEdit(note) {
setEditId(note.id);
setEditText(note.content);
}
async function saveEdit(id) {
setSaving(true);
try {
const r = await fetch(`/api/admin/zero/notes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editText }),
});
if (r.ok) { flash('✓ Сохранено'); setEditId(null); load(); }
else { const d = await r.json(); flash('✗ ' + d.error); }
} catch (e) { flash('✗ ' + e.message); }
setSaving(false);
}
// ────── filter + counts ──────
const counts = notes.reduce((acc, n) => { acc[n.status] = (acc[n.status] || 0) + 1; return acc; }, {});
const filtered = filter === 'all' ? notes : notes.filter(n => n.status === filter);
return (
<div className="space-y-5">
{/* HEADER */}
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold">Заметки от Зеро</h2>
<p className="text-xs text-gray-400 mt-0.5">Короткие посты в @zeropostru от AI-персонажа · 13:00 МСК ежедневно</p>
</div>
<div className="flex items-center gap-2">
{msg && <span className="text-sm text-emerald-400">{msg}</span>}
<button onClick={() => setGenOpen(v => !v)} className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" /> Сгенерировать
</button>
<button onClick={load} className="btn-ghost p-2">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* GENERATE FORM */}
{genOpen && (
<div className="card p-4 border-accent/30 bg-accent/5 space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Zap className="w-4 h-4 text-accent" /> Новый черновик
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="label text-xs">Канал (channel_id)</label>
<input type="number" value={genForm.channel_id}
onChange={e => setGenForm(f => ({ ...f, channel_id: e.target.value }))}
className="input text-sm py-1.5" />
</div>
<div>
<label className="label text-xs">Ведро темы</label>
<select value={genForm.force_bucket}
onChange={e => setGenForm(f => ({ ...f, force_bucket: e.target.value }))}
className="input text-sm py-1.5">
<option value="">Случайное (anti-repeat)</option>
{buckets.map(b => <option key={b.key} value={b.key}>{b.label}</option>)}
</select>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={genForm.allow_today_dup}
onChange={e => setGenForm(f => ({ ...f, allow_today_dup: e.target.checked }))} />
Разрешить второй черновик за день
</label>
</div>
</div>
<div className="flex gap-2">
<button onClick={generate} disabled={generating}
className="btn-primary px-4 py-1.5 text-sm flex items-center gap-1.5">
{generating ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />}
{generating ? 'Генерация ~20-30 сек…' : 'Запустить'}
</button>
<button onClick={() => setGenOpen(false)} className="btn-ghost px-3 py-1.5 text-sm">Отмена</button>
</div>
</div>
)}
{/* FILTER TABS */}
<div className="flex flex-wrap gap-1.5 text-sm">
<FilterTab active={filter === 'all'} onClick={() => setFilter('all')} label={`Все · ${notes.length}`} />
{Object.entries(STATUS_META).map(([key, meta]) => {
const cnt = counts[key] || 0;
if (cnt === 0 && key !== 'draft' && key !== 'approved') return null;
return <FilterTab key={key} active={filter === key} onClick={() => setFilter(key)}
label={`${meta.label} · ${cnt}`} colorClass={meta.color} />;
})}
</div>
{/* LIST */}
{loading && notes.length === 0 && (
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
)}
{!loading && filtered.length === 0 && (
<div className="card p-8 text-center text-sm text-gray-400">
Заметок в этом разделе нет. Жми «Сгенерировать» чтобы создать черновик.
</div>
)}
<div className="space-y-3">
{filtered.map(note => (
<NoteCard key={note.id} note={note}
buckets={buckets}
isEditing={editId === note.id}
editText={editText}
setEditText={setEditText}
saving={saving}
onStartEdit={() => startEdit(note)}
onCancelEdit={() => setEditId(null)}
onSaveEdit={() => saveEdit(note.id)}
onApprove={() => approve(note.id)}
onSkip={() => skip(note.id)}
isRegen={regenId === note.id}
regenBucket={regenBucket}
setRegenBucket={setRegenBucket}
onRegenStart={() => { setRegenId(note.id); setRegenBucket(''); }}
onRegenCancel={() => setRegenId(null)}
onRegenConfirm={() => regenerate(note.id)} />
))}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Sub-components
// ──────────────────────────────────────────────────────────────
function FilterTab({ active, onClick, label, colorClass }) {
return (
<button onClick={onClick}
className={`px-3 py-1 rounded-md border transition-colors ${
active ? 'border-accent text-accent bg-accent/10' : 'border-border text-gray-400 hover:bg-surface2'
} ${colorClass || ''}`}>
{label}
</button>
);
}
function NoteCard({
note, buckets,
isEditing, editText, setEditText, saving, onStartEdit, onCancelEdit, onSaveEdit,
onApprove, onSkip,
isRegen, regenBucket, setRegenBucket, onRegenStart, onRegenCancel, onRegenConfirm,
}) {
const meta = STATUS_META[note.status] || STATUS_META.draft;
const Icon = bucketIcon(note.theme_bucket);
const bucketLabel = buckets.find(b => b.key === note.theme_bucket)?.label || note.theme_bucket;
const canApprove = note.status === 'draft';
const canEdit = ['draft', 'approved'].includes(note.status);
const canRegen = ['draft', 'failed'].includes(note.status);
const canSkip = ['draft', 'approved'].includes(note.status);
return (
<div className={`card p-4 border ${meta.border}`}>
{/* HEADER */}
<div className="flex items-start gap-3 mb-3">
<Icon className="w-5 h-5 text-accent shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-mono text-gray-500">#{note.id}</span>
<span className={`px-1.5 py-0.5 rounded ${meta.bg} ${meta.color} font-medium`}>{meta.label}</span>
<span className="text-gray-400">· {bucketLabel}</span>
{note.pose && <span className="text-gray-500">· поза: {note.pose}</span>}
<span className="text-gray-500">· {note.status === 'published'
? `опубликовано ${mskTime(note.published_at)}`
: `на ${mskTime(note.scheduled_at)} МСК`}</span>
</div>
{note.theme && <div className="text-xs text-gray-400 mt-1 truncate">тема: {note.theme}</div>}
</div>
</div>
{/* CONTENT or EDIT */}
{isEditing ? (
<div className="space-y-2">
<textarea value={editText} onChange={e => setEditText(e.target.value)}
rows={Math.max(6, editText.split('\n').length + 1)}
className="input w-full text-sm font-mono resize-y" />
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{wordCount(editText)} слов · {editText.length} символов</span>
<div className="flex-1" />
<button onClick={onCancelEdit} className="btn-ghost px-3 py-1 text-xs flex items-center gap-1">
<X className="w-3 h-3" /> Отмена
</button>
<button onClick={onSaveEdit} disabled={saving}
className="btn-primary px-3 py-1 text-xs flex items-center gap-1">
{saving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Сохранить
</button>
</div>
</div>
) : (
<div className="text-sm whitespace-pre-wrap leading-relaxed">{note.content}</div>
)}
{/* ERROR */}
{note.error && (
<div className="mt-3 text-xs text-red-400 bg-red-500/5 border border-red-500/20 rounded p-2">
Ошибка: {note.error}
{note.attempts > 0 && <span className="text-gray-500"> · попыток: {note.attempts}</span>}
</div>
)}
{/* REGEN PICKER */}
{isRegen && (
<div className="mt-3 p-3 border border-accent/30 bg-accent/5 rounded-lg space-y-2">
<div className="text-xs text-gray-400">Выбери ведро для перегенерации (или оставь пусто для anti-repeat):</div>
<select value={regenBucket} onChange={e => setRegenBucket(e.target.value)}
className="input text-sm py-1.5">
<option value="">Случайное (anti-repeat)</option>
{buckets.map(b => <option key={b.key} value={b.key}>{b.label}</option>)}
</select>
<div className="flex gap-2">
<button onClick={onRegenConfirm} className="btn-primary px-3 py-1 text-xs flex items-center gap-1">
<Zap className="w-3 h-3" /> Перегенерировать
</button>
<button onClick={onRegenCancel} className="btn-ghost px-3 py-1 text-xs">Отмена</button>
</div>
</div>
)}
{/* ACTIONS */}
{!isEditing && !isRegen && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
{canApprove && (
<button onClick={onApprove} className="btn-primary px-3 py-1 flex items-center gap-1">
<Check className="w-3 h-3" /> Одобрить
</button>
)}
{canEdit && (
<button onClick={onStartEdit} className="btn-ghost px-3 py-1 flex items-center gap-1">
<Edit3 className="w-3 h-3" /> Редактировать
</button>
)}
{canRegen && (
<button onClick={onRegenStart} className="btn-ghost px-3 py-1 flex items-center gap-1">
<RefreshCw className="w-3 h-3" /> Перегенерировать
</button>
)}
{canSkip && (
<button onClick={onSkip} className="btn-ghost px-3 py-1 flex items-center gap-1 text-gray-500 hover:text-red-400">
<Trash2 className="w-3 h-3" /> Пропустить
</button>
)}
{note.status === 'published' && note.channel_message_id && (
<span className="text-gray-500 flex items-center gap-1">
<Send className="w-3 h-3" /> TG msg #{note.channel_message_id}
</span>
)}
<div className="flex-1" />
<span className="text-gray-600 font-mono">
{note.model || ''}
{note.tokens_in ? ` · ${note.tokens_in}${note.tokens_out} tok` : ''}
</span>
</div>
)}
</div>
);
}