feat(categories): CRUD панель тем внутри карточки категории
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).
This commit is contained in:
@@ -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 => (
|
||||
<CategoryCard
|
||||
key={c.id} cat={c}
|
||||
expanded={expandedId === c.id}
|
||||
onToggleExpand={() => setExpandedId(expandedId === c.id ? null : c.id)}
|
||||
onEdit={() => startEdit(c)}
|
||||
onToggle={() => toggleActive(c)}
|
||||
onHardDelete={() => hardDelete(c)}
|
||||
onTopicsChange={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className={`rounded-xl border-2 p-5 transition-all ${colorCls(cat.color)} ${archived ? 'opacity-50' : ''}`}>
|
||||
<div className={`rounded-xl border-2 p-5 transition-all ${colorCls(cat.color)} ${archived ? 'opacity-50' : ''} ${expanded ? 'lg:col-span-3 sm:col-span-2' : ''}`}>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="text-3xl shrink-0">{cat.icon || '📝'}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -268,9 +273,12 @@ function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) {
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs opacity-70 mb-3">
|
||||
<span className="inline-flex items-center gap-1"><FileText className="w-3 h-3" /> {cat.article_count} статей</span>
|
||||
<span className="inline-flex items-center gap-1"><ListChecks className="w-3 h-3" /> {cat.topic_count} тем
|
||||
<button onClick={onToggleExpand}
|
||||
className="inline-flex items-center gap-1 hover:opacity-100 hover:underline transition-opacity">
|
||||
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
<ListChecks className="w-3 h-3" /> {cat.topic_count} тем
|
||||
{cat.topic_unused_count > 0 && <span className="opacity-60">({cat.topic_unused_count} свободных)</span>}
|
||||
</span>
|
||||
</button>
|
||||
{cat.autogen_enabled && cat.run_hour != null && (
|
||||
<span>авто: {String(cat.run_hour).padStart(2,'0')}:{String(cat.run_minute || 0).padStart(2,'0')}</span>
|
||||
)}
|
||||
@@ -288,7 +296,14 @@ function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) {
|
||||
<Trash2 className="w-3 h-3" /> Удалить
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onToggleExpand} className="flex items-center gap-1 px-2.5 py-1.5 rounded hover:bg-white/40 dark:hover:bg-black/20 transition-colors opacity-70">
|
||||
{expanded ? 'Свернуть' : 'Темы'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Раскрывающаяся панель тем */}
|
||||
{expanded && <CategoryTopicsPanel category={cat} onChange={onTopicsChange} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user