From 6857b15771728cc623ace860db648351e977c039 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 12:07:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(categories):=20CRUD=20=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D1=82=D0=B5=D0=BC=20=D0=B2=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CategoryTopicsPanel (новый компонент): - GET/POST/PATCH/DELETE topics через /admin/api/blog-topics/* proxy - inline-добавление темы (text + priority p1-p10) - toggle is_used (вернуть в банк / пометить использованной) - редактирование через hover edit-кнопку, Enter сохраняет, Esc отменяет - AI-генерация N тем кнопкой 'Сгенерировать через AI' (5-50 шт) - tabs 'свободные / все' с счётчиками Categories page: - кнопка 'Темы' раскрывает категорию на всю ширину сетки - клик по '{N} тем' счётчику тоже раскрывает - onTopicsChange прокидывает refresh списка категорий (counts обновляются) Proxy: /admin/api/blog-topics/[...path] — catch-all к engine, передаёт x-user-id=1 для совместимости с dev2 (где есть users.is_admin). --- app/admin/(protected)/categories/page.js | 25 +- app/admin/api/blog-topics/[...path]/route.js | 43 ++++ components/admin/CategoryTopicsPanel.js | 236 +++++++++++++++++++ 3 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 app/admin/api/blog-topics/[...path]/route.js create mode 100644 components/admin/CategoryTopicsPanel.js diff --git a/app/admin/(protected)/categories/page.js b/app/admin/(protected)/categories/page.js index 0d09bf0..4605f04 100644 --- a/app/admin/(protected)/categories/page.js +++ b/app/admin/(protected)/categories/page.js @@ -2,8 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import { Loader2, Plus, Save, X, Edit3, Archive, ArchiveRestore, Trash2, - RefreshCw, FolderPlus, FileText, ListChecks, Eye, EyeOff, + RefreshCw, FolderPlus, FileText, ListChecks, Eye, EyeOff, ChevronDown, ChevronRight, } from 'lucide-react'; +import CategoryTopicsPanel from '@/components/admin/CategoryTopicsPanel'; const COLORS = [ { key: 'emerald', cls: 'bg-emerald-100 border-emerald-300 text-emerald-700 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-300' }, @@ -30,6 +31,7 @@ export default function CategoriesPage() { const [form, setForm] = useState(EMPTY); const [saving, setSaving] = useState(false); const [showArchived, setShowArchived] = useState(false); + const [expandedId, setExpandedId] = useState(null); // id раскрытой категории — её темы видны const [toast, setToast] = useState(null); const [err, setErr] = useState(''); @@ -223,9 +225,12 @@ export default function CategoriesPage() { {visible.map(c => ( setExpandedId(expandedId === c.id ? null : c.id)} onEdit={() => startEdit(c)} onToggle={() => toggleActive(c)} onHardDelete={() => hardDelete(c)} + onTopicsChange={load} /> ))} @@ -245,10 +250,10 @@ function Field({ label, hint, full = '', children }) { ); } -function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) { +function CategoryCard({ cat, expanded, onToggleExpand, onEdit, onToggle, onHardDelete, onTopicsChange }) { const archived = cat.is_active === false; return ( -
+
{cat.icon || '📝'}
@@ -268,9 +273,12 @@ function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) {
{cat.article_count} статей - {cat.topic_count} тем + {cat.autogen_enabled && cat.run_hour != null && ( авто: {String(cat.run_hour).padStart(2,'0')}:{String(cat.run_minute || 0).padStart(2,'0')} )} @@ -288,7 +296,14 @@ function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) { Удалить )} +
+
+ + {/* Раскрывающаяся панель тем */} + {expanded && }
); } diff --git a/app/admin/api/blog-topics/[...path]/route.js b/app/admin/api/blog-topics/[...path]/route.js new file mode 100644 index 0000000..8ce181c --- /dev/null +++ b/app/admin/api/blog-topics/[...path]/route.js @@ -0,0 +1,43 @@ +/** + * Catch-all proxy для /admin/api/blog-topics/* → engine /api/admin/blog-topics/* + */ +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/blog-topics${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`; + + const headers = { + 'x-internal-secret': S, + 'x-user-id': '1', // на случай если у engine есть users.is_admin (dev), на проде игнорится + }; + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + body = (await req.text()) || undefined; + } + + try { + 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 }); + } catch (err) { + console.error('[blog-topics proxy] fetch error:', err.message); + return NextResponse.json({ error: err.message }, { status: 502 }); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PATCH = proxy; +export const PUT = proxy; +export const DELETE = proxy; diff --git a/components/admin/CategoryTopicsPanel.js b/components/admin/CategoryTopicsPanel.js new file mode 100644 index 0000000..8e5aeb2 --- /dev/null +++ b/components/admin/CategoryTopicsPanel.js @@ -0,0 +1,236 @@ +'use client'; +import { useState, useEffect, useCallback } from 'react'; +import { + Loader2, Plus, Trash2, Edit3, Save, X, Sparkles, RefreshCw, + CheckCircle, Circle, ListChecks, +} from 'lucide-react'; + +/** + * Панель CRUD тем для одной категории. + * Рендерится в развёрнутом виде под/в карточке категории. + * + * Раскрытие лениво подгружает темы и держит их в локальном стейте. + */ +export default function CategoryTopicsPanel({ category, onChange }) { + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(true); + const [includeUsed, setIncludeUsed] = useState(false); + const [newTopic, setNewTopic] = useState(''); + const [newPriority, setNewPriority] = useState(5); + const [adding, setAdding] = useState(false); + const [editId, setEditId] = useState(null); + const [editText, setEditText] = useState(''); + const [editPriority, setEditPriority] = useState(5); + const [genCount, setGenCount] = useState(10); + const [generating, setGenerating] = useState(false); + const [toast, setToast] = useState(null); + + const flash = (msg, type='success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); }; + + const load = useCallback(async () => { + setLoading(true); + try { + const r = await fetch(`/admin/api/blog-topics?category=${encodeURIComponent(category.slug)}&includeUsed=${includeUsed}&limit=200`); + const d = await r.json(); + setTopics(d.topics || []); + } catch (e) { flash('Ошибка загрузки: ' + e.message, 'error'); } + setLoading(false); + }, [category.slug, includeUsed]); + + useEffect(() => { load(); }, [load]); + + async function addTopic() { + if (!newTopic.trim()) return; + setAdding(true); + try { + const r = await fetch('/admin/api/blog-topics', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: category.slug, topic: newTopic.trim(), priority: newPriority }), + }); + const d = await r.json(); + if (!r.ok || d.error) throw new Error(d.error || 'fail'); + flash('Тема добавлена'); + setNewTopic(''); + await load(); + onChange?.(); + } catch (e) { flash(e.message, 'error'); } + setAdding(false); + } + + async function saveEdit() { + if (!editText.trim()) return; + try { + const r = await fetch(`/admin/api/blog-topics/${editId}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic: editText.trim(), priority: editPriority }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'fail'); + flash('Сохранено'); + setEditId(null); + await load(); + } catch (e) { flash(e.message, 'error'); } + } + + async function toggleUsed(t) { + const r = await fetch(`/admin/api/blog-topics/${t.id}`, { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_used: !t.is_used }), + }); + if (r.ok) { flash(t.is_used ? 'Тема вернулась в банк' : 'Помечена использованной'); load(); onChange?.(); } + } + + async function del(t) { + if (!confirm(`Удалить тему: «${t.topic.slice(0, 60)}»?`)) return; + const r = await fetch(`/admin/api/blog-topics/${t.id}`, { method: 'DELETE' }); + if (r.ok) { flash('Тема удалена'); load(); onChange?.(); } + } + + async function generateAI() { + if (!confirm(`Сгенерировать ${genCount} новых тем через AI для «${category.name}»? Существующие останутся.`)) return; + setGenerating(true); + try { + const r = await fetch('/admin/api/blog-topics/generate', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: category.slug, count: genCount }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'fail'); + flash(`AI генерирует ~${genCount} тем, появятся через 10-20 сек`); + // Подождём и обновим + setTimeout(() => { load(); onChange?.(); }, 12000); + } catch (e) { flash(e.message, 'error'); } + setGenerating(false); + } + + const unusedCount = topics.filter(t => !t.is_used).length; + const usedCount = topics.filter(t => t.is_used).length; + + return ( +
+ {toast && ( +
{toast.msg}
+ )} + + {/* HEADER */} +
+
+ + Темы для автогенерации +
+
+ + +
+
+ + {/* ADD FORM */} +
+ setNewTopic(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addTopic()} + placeholder="Новая тема статьи..." + className="flex-1 px-3 py-1.5 rounded-lg border border-current/20 bg-white/70 dark:bg-black/30 text-xs focus:outline-none focus:ring-2 focus:ring-emerald-500" /> + + +
+ + {/* AI GENERATE */} +
+ + +
+ + {/* LIST */} + {loading && topics.length === 0 && ( +
+ )} + {!loading && topics.length === 0 && ( +
+ {includeUsed ? 'Тем пока нет — добавь или сгенерируй' : 'Свободных тем нет — посмотри использованные или добавь новые'} +
+ )} + +
+ {topics.map(t => ( + { setEditId(t.id); setEditText(t.topic); setEditPriority(t.priority || 5); }} + onEditCancel={() => setEditId(null)} + onEditChange={setEditText} + onPriorityChange={setEditPriority} + onSaveEdit={saveEdit} + onToggleUsed={() => toggleUsed(t)} + onDelete={() => del(t)} /> + ))} +
+
+ ); +} + +function TopicRow({ t, isEditing, editText, editPriority, onEditStart, onEditCancel, onEditChange, onPriorityChange, onSaveEdit, onToggleUsed, onDelete }) { + if (isEditing) { + return ( +
+ onEditChange(e.target.value)} autoFocus + onKeyDown={e => { if (e.key === 'Enter') onSaveEdit(); if (e.key === 'Escape') onEditCancel(); }} + className="flex-1 px-2 py-1 rounded border border-current/30 bg-white dark:bg-neutral-900 text-xs focus:outline-none focus:ring-1 focus:ring-emerald-500" /> + + + +
+ ); + } + + return ( +
+ + {t.topic} + + p{t.priority || 5} + {t.source === 'ai' && ' · ai'} + + + +
+ ); +}