feat: /admin/drafts — draft review page with approve/regen cover

This commit is contained in:
Nik (Claude)
2026-06-16 09:19:13 +03:00
parent d6cf4d3f0e
commit b9abbc4246
2 changed files with 339 additions and 1 deletions
+337
View File
@@ -0,0 +1,337 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { CheckCircle, RefreshCw, Pencil, Clock, ImageIcon, ChevronDown, ChevronUp, X, Save } from 'lucide-react';
export const metadata = { title: 'Черновики' };
const COVER_STYLES = [
{ id: 'tech-photo', name: 'Tech Photo — реальное железо' },
{ id: '3d-device', name: '3D Device — рендер устройства' },
{ id: 'code-screen', name: 'Code Screen — экран с кодом' },
{ id: 'data-flow', name: 'Data Flow — абстракция данных' },
{ id: 'ai-neural', name: 'AI Neural — нейросетевая эстетика' },
{ id: 'cinematic-tech', name: 'Cinematic — кинематографичный' },
];
const CATEGORY_LABELS = {
'ai-tools': '🤖 AI Tools',
'cybersec': '🔒 Cybersec',
'automation': '⚡ Automation',
'ai-dev': '💻 AI Dev',
};
function DraftCard({ draft, onApproved, onCoverRegenerated }) {
const [loading, setLoading] = useState(false);
const [regenLoading, setRegenLoading] = useState(false);
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(draft.title);
const [editExcerpt, setEditExcerpt] = useState(draft.excerpt || '');
const [saveLoading, setSaveLoading] = useState(false);
const [selectedStyle, setSelectedStyle] = useState('');
const [coverUrl, setCoverUrl] = useState(draft.cover_url);
const [msg, setMsg] = useState('');
const engineUrl = process.env.NEXT_PUBLIC_ENGINE_URL || '';
async function approve() {
setLoading(true);
setMsg('');
try {
const r = await fetch(`${engineUrl}/api/drafts/${draft.id}/approve`, {
method: 'PATCH',
headers: { 'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '' },
});
const d = await r.json();
if (d.ok) {
const slot = d.scheduled_at
? new Date(d.scheduled_at).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' })
: null;
setMsg(slot ? `✅ Одобрено, выйдет в ${slot}` : '✅ Одобрено');
setTimeout(() => onApproved(draft.id), 1200);
} else {
setMsg('❌ ' + (d.error || 'Ошибка'));
}
} catch { setMsg('❌ Ошибка сети'); }
setLoading(false);
}
async function regenCover() {
setRegenLoading(true);
setMsg('');
try {
const r = await fetch(`${engineUrl}/api/drafts/${draft.id}/regenerate-cover`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '',
},
body: JSON.stringify({ style: selectedStyle || undefined }),
});
const d = await r.json();
if (d.ok && d.cover_url) {
setCoverUrl(d.cover_url);
setMsg('🎨 Обложка обновлена');
onCoverRegenerated(draft.id, d.cover_url);
} else {
setMsg('❌ ' + (d.error || 'Ошибка генерации'));
}
} catch { setMsg('❌ Ошибка сети'); }
setRegenLoading(false);
}
async function saveEdit() {
setSaveLoading(true);
try {
const r = await fetch(`${engineUrl}/api/drafts/${draft.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '',
},
body: JSON.stringify({ title: editTitle, excerpt: editExcerpt }),
});
const d = await r.json();
if (d.ok) { setEditing(false); setMsg('💾 Сохранено'); }
else setMsg('❌ ' + (d.error || 'Ошибка'));
} catch { setMsg('❌ Ошибка сети'); }
setSaveLoading(false);
}
const coverSrc = coverUrl
? (coverUrl.startsWith('http') ? coverUrl : `${engineUrl}${coverUrl}`)
: null;
return (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
{/* Обложка + основная инфо */}
<div className="flex gap-4 p-4">
<div className="w-32 h-20 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 shrink-0 relative">
{coverSrc
? <img src={coverSrc} alt="" className="w-full h-full object-cover" />
: <div className="w-full h-full flex items-center justify-center text-neutral-400"><ImageIcon className="w-6 h-6" /></div>
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
{CATEGORY_LABELS[draft.category] || draft.category}
</span>
<span className="text-xs text-neutral-400 flex items-center gap-1">
<Clock className="w-3 h-3" /> {draft.reading_time} мин
</span>
</div>
{editing ? (
<input
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full font-semibold text-sm border border-neutral-300 dark:border-neutral-700 rounded px-2 py-1 bg-transparent mb-1"
/>
) : (
<div className="font-semibold text-sm text-neutral-900 dark:text-neutral-100 line-clamp-2 leading-snug">
{editTitle}
</div>
)}
</div>
</div>
{/* Анонс */}
<div className="px-4 pb-3">
{editing ? (
<textarea
value={editExcerpt}
onChange={e => setEditExcerpt(e.target.value)}
rows={3}
className="w-full text-xs border border-neutral-300 dark:border-neutral-700 rounded px-2 py-1.5 bg-transparent resize-none"
/>
) : (
<p className="text-xs text-neutral-500 line-clamp-3 leading-relaxed">{draft.excerpt}</p>
)}
</div>
{/* Сообщение */}
{msg && (
<div className="px-4 pb-2 text-xs font-medium text-neutral-700 dark:text-neutral-300">{msg}</div>
)}
{/* Перегенерация обложки — разворачиваемый блок */}
<div className="border-t border-neutral-100 dark:border-neutral-800">
<button
onClick={() => setExpanded(v => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs text-neutral-500 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
>
<span className="flex items-center gap-1.5"><ImageIcon className="w-3.5 h-3.5" /> Перегенерировать обложку</span>
{expanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
{expanded && (
<div className="px-4 pb-3 space-y-2">
<select
value={selectedStyle}
onChange={e => setSelectedStyle(e.target.value)}
className="w-full text-xs border border-neutral-200 dark:border-neutral-700 rounded-lg px-2 py-1.5 bg-white dark:bg-neutral-800"
>
<option value=""> Авто (по рубрике AI)</option>
{COVER_STYLES.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
onClick={regenCover}
disabled={regenLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-900 text-xs font-medium hover:opacity-80 disabled:opacity-50 transition"
>
<RefreshCw className={`w-3.5 h-3.5 ${regenLoading ? 'animate-spin' : ''}`} />
{regenLoading ? 'Генерирую...' : 'Перегенерировать'}
</button>
</div>
)}
</div>
{/* Кнопки действий */}
<div className="border-t border-neutral-100 dark:border-neutral-800 px-4 py-3 flex items-center gap-2">
{editing ? (
<>
<button
onClick={saveEdit}
disabled={saveLoading}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition"
>
<Save className="w-3.5 h-3.5" />
{saveLoading ? 'Сохраняю...' : 'Сохранить'}
</button>
<button
onClick={() => { setEditing(false); setEditTitle(draft.title); setEditExcerpt(draft.excerpt || ''); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-xs text-neutral-600 dark:text-neutral-300 hover:opacity-80 transition"
>
<X className="w-3.5 h-3.5" /> Отмена
</button>
</>
) : (
<button
onClick={() => setEditing(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-xs text-neutral-600 dark:text-neutral-300 hover:opacity-80 transition"
>
<Pencil className="w-3.5 h-3.5" /> Редактировать
</button>
)}
<button
onClick={approve}
disabled={loading}
className="ml-auto flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-semibold disabled:opacity-50 transition"
>
<CheckCircle className="w-3.5 h-3.5" />
{loading ? 'Одобряю...' : 'Одобрить и запланировать'}
</button>
</div>
</div>
);
}
export default function AdminDraftsPage() {
const [drafts, setDrafts] = useState([]);
const [loading, setLoading] = useState(true);
const [approveAllLoading, setApproveAllLoading] = useState(false);
const [msg, setMsg] = useState('');
const engineUrl = typeof window !== 'undefined'
? (process.env.NEXT_PUBLIC_ENGINE_URL || '')
: '';
const load = useCallback(async () => {
setLoading(true);
try {
const r = await fetch(`${engineUrl}/api/drafts`, {
headers: { 'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '' },
cache: 'no-store',
});
const d = await r.json();
setDrafts(Array.isArray(d) ? d : []);
} catch { setDrafts([]); }
setLoading(false);
}, [engineUrl]);
useEffect(() => { load(); }, [load]);
function handleApproved(id) {
setDrafts(prev => prev.filter(d => d.id !== id));
}
function handleCoverRegenerated(id, coverUrl) {
setDrafts(prev => prev.map(d => d.id === id ? { ...d, cover_url: coverUrl } : d));
}
async function approveAll() {
setApproveAllLoading(true);
setMsg('');
try {
const r = await fetch(`${engineUrl}/api/drafts/approve-all`, {
method: 'POST',
headers: { 'x-internal-secret': process.env.NEXT_PUBLIC_ENGINE_SECRET || '' },
});
const d = await r.json();
if (d.ok) { setMsg('✅ Все черновики одобрены'); load(); }
else setMsg('❌ ' + (d.error || 'Ошибка'));
} catch { setMsg('❌ Ошибка сети'); }
setApproveAllLoading(false);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Черновики</h1>
<p className="text-sm text-neutral-500 mt-0.5">
Статьи ждут проверки. Если не одобришь вручную автоматически опубликуются в 07:00 МСК.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={load}
className="p-2 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition"
>
<RefreshCw className="w-4 h-4 text-neutral-500" />
</button>
{drafts.length > 0 && (
<button
onClick={approveAll}
disabled={approveAllLoading}
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 disabled:opacity-50 transition"
>
<CheckCircle className="w-4 h-4" />
{approveAllLoading ? 'Одобряю...' : `Одобрить все (${drafts.length})`}
</button>
)}
</div>
</div>
{msg && (
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 rounded-lg px-4 py-2.5">
{msg}
</div>
)}
{loading ? (
<div className="text-center py-20 text-neutral-400">Загружаю черновики...</div>
) : drafts.length === 0 ? (
<div className="text-center py-20">
<CheckCircle className="w-10 h-10 text-emerald-400 mx-auto mb-3" />
<div className="font-medium text-neutral-700 dark:text-neutral-300">Черновиков нет</div>
<div className="text-sm text-neutral-400 mt-1">Следующие появятся после autogen</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
{drafts.map(draft => (
<DraftCard
key={draft.id}
draft={draft}
onApproved={handleApproved}
onCoverRegenerated={handleCoverRegenerated}
/>
))}
</div>
)}
</div>
);
}