Files
zeropost-web/components/admin/ArticleEditor.js
T

365 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Save, Trash2, RefreshCw, Eye, Sparkles } from 'lucide-react';
export default function ArticleEditor({ article }) {
const router = useRouter();
const isNew = !article;
const [title, setTitle] = useState(article?.title || '');
const [excerpt, setExcerpt] = useState(article?.excerpt || '');
const [content, setContent] = useState(article?.content || '');
const [tags, setTags] = useState((article?.tags || []).join(', '));
const [status, setStatus] = useState(article?.status || 'draft');
const [category, setCategory] = useState(article?.category || 'ai-tools');
const [seoTitle, setSeoTitle] = useState(article?.seo_title || '');
const [seoDescr, setSeoDescr] = useState(article?.seo_descr || '');
const [coverUrl, setCoverUrl] = useState(article?.cover_url || '');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [regenerating, setRegenerating] = useState(false);
const [generating, setGenerating] = useState(false);
const [toast, setToast] = useState(null);
const [genTopic, setGenTopic] = useState('');
const [showGenModal, setShowGenModal] = useState(false);
function showToast(msg, type = 'success') {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
}
async function save() {
if (!title.trim()) return showToast('Укажите заголовок', 'error');
setSaving(true);
try {
const body = {
title: title.trim(),
excerpt: excerpt.trim(),
content: content.trim(),
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
status,
category,
seo_title: seoTitle.trim() || null,
seo_descr: seoDescr.trim() || null,
cover_url: coverUrl.trim() || null,
};
if (isNew) {
// Генерируем через engine
const res = await fetch('/admin/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
const created = await res.json();
showToast('Статья создана');
router.push(`/admin/articles/${created.id}`);
} else {
const res = await fetch(`/admin/api/articles/${article.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await res.text());
showToast('Сохранено');
router.refresh();
}
} catch (e) {
showToast(e.message.slice(0, 100), 'error');
} finally {
setSaving(false);
}
}
async function deleteArticle() {
if (!confirm('Удалить статью? Это действие необратимо.')) return;
setDeleting(true);
try {
const res = await fetch(`/admin/api/articles/${article.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
router.push('/admin/articles');
} catch (e) {
showToast(e.message.slice(0, 100), 'error');
setDeleting(false);
}
}
async function regenerateCover() {
if (!article?.id) return;
setRegenerating(true);
try {
const res = await fetch(`/admin/api/articles/${article.id}/cover`, { method: 'POST' });
if (!res.ok) throw new Error(await res.text());
const { cover_url } = await res.json();
setCoverUrl(cover_url);
showToast('Обложка обновлена');
router.refresh();
} catch (e) {
showToast('Ошибка: ' + e.message.slice(0, 80), 'error');
} finally {
setRegenerating(false);
}
}
async function generateWithAI() {
if (!genTopic.trim()) return;
setGenerating(true);
setShowGenModal(false);
try {
const res = await fetch('/admin/api/articles/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic: genTopic, autoPublish: false }),
});
if (!res.ok) throw new Error(await res.text());
const a = await res.json();
router.push(`/admin/articles/${a.id}`);
} catch (e) {
showToast('Ошибка генерации: ' + e.message.slice(0, 80), 'error');
setGenerating(false);
}
}
const ENGINE_URL = process.env.NEXT_PUBLIC_ENGINE_URL || '';
return (
<div className="space-y-6 max-w-4xl">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg transition-all ${
toast.type === 'error'
? 'bg-red-500 text-white'
: 'bg-emerald-500 text-white'
}`}>
{toast.msg}
</div>
)}
{/* Заголовок страницы */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/articles" className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors text-neutral-400">
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
{isNew ? 'Новая статья' : 'Редактировать статью'}
</h1>
</div>
<div className="flex items-center gap-2">
{isNew && (
<button
onClick={() => setShowGenModal(true)}
className="inline-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"
>
<Sparkles className="w-4 h-4 text-emerald-500" />
{generating ? 'Генерация...' : 'AI-генерация'}
</button>
)}
{!isNew && article.slug && (
<Link
href={`/blog/${article.slug}`}
target="_blank"
className="inline-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"
>
<Eye className="w-4 h-4" />
Просмотр
</Link>
)}
{!isNew && (
<button
onClick={deleteArticle}
disabled={deleting}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
{deleting ? 'Удаление...' : 'Удалить'}
</button>
)}
<button
onClick={save}
disabled={saving}
className="inline-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"
>
<Save className="w-4 h-4" />
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{/* AI-генерация modal */}
{showGenModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowGenModal(false)}>
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-xl p-6 w-full max-w-md mx-4" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-neutral-900 dark:text-neutral-100 mb-4">Сгенерировать статью AI</h2>
<input
type="text"
value={genTopic}
onChange={e => setGenTopic(e.target.value)}
placeholder="Тема статьи, например: промпт-инжиниринг для e-commerce"
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 mb-4"
autoFocus
onKeyDown={e => e.key === 'Enter' && generateWithAI()}
/>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowGenModal(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>
<button
onClick={generateWithAI}
disabled={!genTopic.trim()}
className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
Генерировать
</button>
</div>
</div>
</div>
)}
<div className="grid lg:grid-cols-[1fr_280px] gap-6">
{/* Основное */}
<div className="space-y-4">
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1.5">Заголовок</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder="Заголовок статьи"
/>
</div>
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1.5">Краткое описание</label>
<textarea
value={excerpt}
onChange={e => setExcerpt(e.target.value)}
rows={2}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none"
placeholder="Краткое описание для карточки и SEO"
/>
</div>
</div>
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1.5">Контент (Markdown)</label>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
rows={24}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y"
placeholder="# Заголовок&#10;&#10;Текст статьи в Markdown..."
/>
</div>
</div>
{/* Боковая панель */}
<div className="space-y-4">
{/* Статус */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide">Публикация</h3>
<select
value={status}
onChange={e => setStatus(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="draft">Черновик</option>
<option value="published">Опубликована</option>
</select>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1">Категория</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<option value="ai-tools">🤖 ИИ-инструменты</option>
<option value="cybersec">🔒 Кибербезопасность</option>
<option value="automation"> Автоматизация</option>
<option value="ai-dev">💻 Разработка с ИИ</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1">Теги (через запятую)</label>
<input
type="text"
value={tags}
onChange={e => setTags(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder="ai, prompts, tools"
/>
</div>
</div>
{/* Обложка */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide">Обложка</h3>
{coverUrl && (
<img src={coverUrl} alt="" className="w-full aspect-video rounded-lg object-cover" />
)}
<input
type="text"
value={coverUrl}
onChange={e => setCoverUrl(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder="/uploads/cover-xxx.webp"
/>
{!isNew && (
<button
onClick={regenerateCover}
disabled={regenerating}
className="w-full inline-flex items-center justify-center gap-2 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 disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${regenerating ? 'animate-spin' : ''}`} />
{regenerating ? 'Генерация...' : 'Перегенерировать AI'}
</button>
)}
</div>
{/* SEO */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-3">
<h3 className="text-xs font-semibold text-neutral-500 uppercase tracking-wide">SEO</h3>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1">Title <span className="text-neutral-300">(если отличается)</span></label>
<input
type="text"
value={seoTitle}
onChange={e => setSeoTitle(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500"
placeholder={title || 'SEO заголовок'}
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-500 mb-1">Description</label>
<textarea
value={seoDescr}
onChange={e => setSeoDescr(e.target.value)}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-none"
placeholder={excerpt || 'SEO описание до 160 символов'}
/>
<div className={`text-xs mt-1 text-right ${seoDescr.length > 160 ? 'text-red-400' : 'text-neutral-300'}`}>
{seoDescr.length}/160
</div>
</div>
</div>
{/* Инфо */}
{!isNew && (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-4 space-y-2 text-xs text-neutral-400">
<div className="flex justify-between"><span>ID</span><span className="font-mono">{article.id}</span></div>
<div className="flex justify-between"><span>Slug</span><span className="font-mono truncate ml-2">{article.slug}</span></div>
{article.reading_time && <div className="flex justify-between"><span>Чтение</span><span>{article.reading_time} мин</span></div>}
{article.published_at && <div className="flex justify-between"><span>Опубликована</span><span>{new Date(article.published_at).toLocaleDateString('ru-RU')}</span></div>}
</div>
)}
</div>
</div>
</div>
);
}