Compare commits
2 Commits
33c11049f1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bc413da50 | |||
| 68fb51fc0a |
@@ -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;
|
||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user