feat(categories): admin section + dynamic categories everywhere

New /admin/categories page:
  - list with icon, name, slug, color preview, article/topic counters
  - new/edit form (slug locked after creation since it's FK in articles/topics)
  - 12 color palette, sort_order, archive toggle (soft delete)
  - hard delete only when archived AND no articles/topics attached

AdminNav: new pin 'Категории' (FolderPlus) in 'Контент' group, before Статьи.

Dynamic categories — hardcoded CAT_LABELS removed from:
  - components/admin/AutogenPanel.js — now accepts categories prop, builds
    lookup map from DB, supports all 12 palette colors
  - app/page.js — CATEGORY_ORDER hardcode removed; renders categories in
    sort_order from DB, skips empty ones

Plumbing: app/admin/api/categories/[...path]/route.js — catch-all proxy
This commit is contained in:
Aleksei Pavlov
2026-06-19 11:57:35 +03:00
parent 0b4895bb97
commit 3f6cd28798
6 changed files with 371 additions and 20 deletions
+3 -2
View File
@@ -26,17 +26,18 @@ async function engineCall(path) {
export default async function AutogenPage() { export default async function AutogenPage() {
await requireAdminAuth(); await requireAdminAuth();
const [status, queue, topics, zeroConfig, zeroNotes] = await Promise.all([ const [status, queue, topics, categories, zeroConfig, zeroNotes] = await Promise.all([
engineCall('/api/autogen/status'), engineCall('/api/autogen/status'),
engineCall('/api/autogen/queue'), engineCall('/api/autogen/queue'),
engineCall('/api/autogen/topics'), engineCall('/api/autogen/topics'),
engineCall('/api/categories'),
engineCall('/api/admin/zero/config'), engineCall('/api/admin/zero/config'),
engineCall('/api/admin/zero/notes?limit=5'), engineCall('/api/admin/zero/notes?limit=5'),
]); ]);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<AutogenPanel status={status || []} queue={queue || []} topics={topics || {}} /> <AutogenPanel status={status || []} queue={queue || []} topics={topics || {}} categories={categories || []} />
{/* Блок Зеро — отдельная карточка рядом с категориями статей */} {/* Блок Зеро — отдельная карточка рядом с категориями статей */}
<div className="border-t border-neutral-200 dark:border-neutral-800 pt-8"> <div className="border-t border-neutral-200 dark:border-neutral-800 pt-8">
+294
View File
@@ -0,0 +1,294 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Loader2, Plus, Save, X, Edit3, Archive, ArchiveRestore, Trash2,
RefreshCw, FolderPlus, FileText, ListChecks, Eye, EyeOff,
} from 'lucide-react';
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' },
{ key: 'red', cls: 'bg-red-100 border-red-300 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300' },
{ key: 'amber', cls: 'bg-amber-100 border-amber-300 text-amber-700 dark:bg-amber-950 dark:border-amber-800 dark:text-amber-300' },
{ key: 'blue', cls: 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-300' },
{ key: 'purple', cls: 'bg-purple-100 border-purple-300 text-purple-700 dark:bg-purple-950 dark:border-purple-800 dark:text-purple-300' },
{ key: 'pink', cls: 'bg-pink-100 border-pink-300 text-pink-700 dark:bg-pink-950 dark:border-pink-800 dark:text-pink-300' },
{ key: 'cyan', cls: 'bg-cyan-100 border-cyan-300 text-cyan-700 dark:bg-cyan-950 dark:border-cyan-800 dark:text-cyan-300' },
{ key: 'orange', cls: 'bg-orange-100 border-orange-300 text-orange-700 dark:bg-orange-950 dark:border-orange-800 dark:text-orange-300' },
{ key: 'lime', cls: 'bg-lime-100 border-lime-300 text-lime-700 dark:bg-lime-950 dark:border-lime-800 dark:text-lime-300' },
{ key: 'rose', cls: 'bg-rose-100 border-rose-300 text-rose-700 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-300' },
{ key: 'slate', cls: 'bg-slate-100 border-slate-300 text-slate-700 dark:bg-slate-900 dark:border-slate-700 dark:text-slate-300' },
{ key: 'neutral', cls: 'bg-neutral-100 border-neutral-300 text-neutral-700 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-300' },
];
const colorCls = (k) => (COLORS.find(c => c.key === k) || COLORS[0]).cls;
const EMPTY = { slug: '', name: '', description: '', icon: '📝', color: 'emerald', sort_order: 99, is_active: true };
export default function CategoriesPage() {
const [items, setItems] = useState([]);
const [loading, setLoad] = useState(true);
const [editing, setEdit] = useState(null); // null | 'new' | category object
const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [toast, setToast] = useState(null);
const [err, setErr] = useState('');
const flash = (msg, type='success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); };
const load = useCallback(async () => {
setLoad(true);
try {
const r = await fetch('/admin/api/categories');
const d = await r.json();
setItems(d.items || []);
} catch (e) { flash('Ошибка загрузки: ' + e.message, 'error'); }
setLoad(false);
}, []);
useEffect(() => { load(); }, [load]);
function startNew() {
setForm({ ...EMPTY, sort_order: Math.max(0, ...items.map(c => c.sort_order || 0)) + 1 });
setEdit('new'); setErr('');
}
function startEdit(c) {
setForm({
slug: c.slug, name: c.name, description: c.description || '',
icon: c.icon || '📝', color: c.color || 'emerald',
sort_order: c.sort_order || 0, is_active: c.is_active !== false,
});
setEdit(c); setErr('');
}
function cancelEdit() { setEdit(null); setErr(''); }
async function save() {
if (!form.name.trim()) { setErr('Название обязательно'); return; }
if (editing === 'new' && !form.slug.trim()) { setErr('Slug обязателен'); return; }
setSaving(true); setErr('');
try {
const url = editing === 'new' ? '/admin/api/categories' : `/admin/api/categories/${editing.id}`;
const method = editing === 'new' ? 'POST' : 'PATCH';
const body = editing === 'new' ? form : { ...form, slug: undefined }; // slug immutable
const r = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || 'fail');
flash(editing === 'new' ? `Категория «${form.name}» создана` : `Сохранено`);
cancelEdit();
await load();
} catch (e) { setErr(e.message); }
setSaving(false);
}
async function toggleActive(c) {
const r = await fetch(`/admin/api/categories/${c.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !c.is_active }),
});
if (r.ok) { flash(c.is_active ? 'Архивирована' : 'Восстановлена'); load(); }
else { const d = await r.json(); flash(d.error || 'fail', 'error'); }
}
async function hardDelete(c) {
if (!confirm(`Удалить «${c.name}» НАВСЕГДА? Это сработает только если у категории нет статей и тем.`)) return;
const r = await fetch(`/admin/api/categories/${c.id}?force=true`, { method: 'DELETE' });
const d = await r.json();
if (r.ok) { flash(`«${c.name}» удалена`); load(); }
else { flash(d.error || 'fail', 'error'); }
}
const visible = showArchived ? items : items.filter(c => c.is_active !== false);
const archivedCount = items.filter(c => c.is_active === false).length;
return (
<div className="space-y-6">
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
}`}>{toast.msg}</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<FolderPlus className="w-6 h-6 text-emerald-500" /> Категории
</h1>
<p className="text-sm text-neutral-500 mt-1">
Структура контента сайта · отображаются в разделе «Темы» и в автогенерации
</p>
</div>
<div className="flex items-center gap-2">
{archivedCount > 0 && (
<button onClick={() => setShowArchived(v => !v)}
className="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">
{showArchived ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
{showArchived ? 'Скрыть архив' : `Архив (${archivedCount})`}
</button>
)}
<button onClick={startNew}
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 transition-colors">
<Plus className="w-4 h-4" /> Новая категория
</button>
<button onClick={load}
className="p-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* EDIT FORM */}
{editing && (
<div className="rounded-xl border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-sm flex items-center gap-2 text-neutral-900 dark:text-neutral-100">
{editing === 'new' ? <><Plus className="w-4 h-4 text-emerald-500" /> Новая категория</> : <><Edit3 className="w-4 h-4 text-emerald-500" /> Редактирование</>}
</h2>
<button onClick={cancelEdit} className="p-1 rounded text-neutral-400 hover:text-neutral-600"><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
<Field label="Иконка (emoji)" full="sm:col-span-2">
<input type="text" value={form.icon} maxLength={4} onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-2xl text-center focus:outline-none focus:ring-2 focus:ring-emerald-500" />
</Field>
<Field label="Название" full="sm:col-span-6">
<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="ИИ-инструменты"
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" />
</Field>
<Field label="Slug (URL)" full="sm:col-span-4" hint={editing !== 'new' ? 'нельзя изменить' : 'a-z, 0-9, -'}>
<input type="text" value={form.slug} disabled={editing !== 'new'}
onChange={e => setForm(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
placeholder="ai-tools"
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm font-mono disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-emerald-500" />
</Field>
<Field label="Описание" full="sm:col-span-12">
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="О чём эта категория — короткий тагалайн (~80 символов)"
rows={2} maxLength={500}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500" />
</Field>
<Field label="Цвет" full="sm:col-span-9">
<div className="flex flex-wrap gap-1.5">
{COLORS.map(c => (
<button key={c.key} type="button" onClick={() => setForm(f => ({ ...f, color: c.key }))}
className={`px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${c.cls} ${
form.color === c.key ? 'ring-2 ring-offset-2 ring-emerald-500 dark:ring-offset-neutral-900' : 'opacity-60 hover:opacity-100'
}`}>
{c.key}
</button>
))}
</div>
</Field>
<Field label="Порядок" full="sm:col-span-3" hint="меньше = выше">
<input type="number" min={0} value={form.sort_order}
onChange={e => setForm(f => ({ ...f, sort_order: parseInt(e.target.value, 10) || 0 }))}
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" />
</Field>
</div>
{err && <div className="text-xs text-red-600 dark:text-red-400">{err}</div>}
<div className="flex gap-2">
<button onClick={save} disabled={saving}
className="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">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{editing === 'new' ? 'Создать' : 'Сохранить'}
</button>
<button onClick={cancelEdit}
className="px-4 py-2 rounded-lg text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">Отмена</button>
</div>
{editing === 'new' && (
<p className="text-xs text-neutral-500">
При создании автоматически добавится строка в <span className="font-mono">autogen_settings</span> с дефолтами:
включено, 1 статья/день, в 12:00 МСК.
</p>
)}
</div>
)}
{/* LIST */}
{loading && items.length === 0 && (
<div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-emerald-500" /></div>
)}
{!loading && visible.length === 0 && (
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 p-10 text-center text-sm text-neutral-400">
{items.length === 0 ? 'Категорий пока нет — нажми «Новая категория»' : 'Активных категорий нет — раскрой архив'}
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{visible.map(c => (
<CategoryCard
key={c.id} cat={c}
onEdit={() => startEdit(c)}
onToggle={() => toggleActive(c)}
onHardDelete={() => hardDelete(c)}
/>
))}
</div>
</div>
);
}
function Field({ label, hint, full = '', children }) {
return (
<div className={full}>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
{label}
{hint && <span className="text-neutral-400 font-normal ml-1.5">{hint}</span>}
</label>
{children}
</div>
);
}
function CategoryCard({ cat, onEdit, onToggle, onHardDelete }) {
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="flex items-start gap-3 mb-3">
<div className="text-3xl shrink-0">{cat.icon || '📝'}</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm leading-tight">{cat.name}</div>
<div className="text-xs opacity-60 font-mono mt-0.5">/{cat.slug}</div>
</div>
{archived && (
<span className="text-xs px-2 py-0.5 rounded-full bg-white/60 dark:bg-black/30 opacity-80">
архив
</span>
)}
</div>
{cat.description && (
<p className="text-xs opacity-80 leading-relaxed mb-3 line-clamp-2">{cat.description}</p>
)}
<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} тем
{cat.topic_unused_count > 0 && <span className="opacity-60">({cat.topic_unused_count} свободных)</span>}
</span>
{cat.autogen_enabled && cat.run_hour != null && (
<span>авто: {String(cat.run_hour).padStart(2,'0')}:{String(cat.run_minute || 0).padStart(2,'0')}</span>
)}
</div>
<div className="flex items-center gap-1 text-xs">
<button onClick={onEdit} className="flex items-center gap-1 px-2.5 py-1.5 rounded bg-white/60 dark:bg-black/20 hover:bg-white dark:hover:bg-black/30 transition-colors font-medium">
<Edit3 className="w-3 h-3" /> Изменить
</button>
<button onClick={onToggle} 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">
{archived ? <><ArchiveRestore className="w-3 h-3" /> Вернуть</> : <><Archive className="w-3 h-3" /> Архив</>}
</button>
{archived && cat.article_count === 0 && cat.topic_count === 0 && (
<button onClick={onHardDelete} className="flex items-center gap-1 px-2.5 py-1.5 rounded text-red-600 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors opacity-70">
<Trash2 className="w-3 h-3" /> Удалить
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* Catch-all proxy для /admin/api/categories/* → engine /api/admin/categories/*
*/
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/categories${tail ? '/' + tail : ''}${qs ? '?' + qs : ''}`;
const headers = { 'x-internal-secret': S };
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('[categories 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;
+10 -8
View File
@@ -17,7 +17,7 @@ import { Sparkles, ArrowRight } from 'lucide-react';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec']; // CATEGORY_ORDER теперь приходит из БД (categories) — sort_order определяет порядок
export default async function HomePage() { export default async function HomePage() {
let home = { hero: null, byCategory: {}, popular: [], recent: [] }; let home = { hero: null, byCategory: {}, popular: [], recent: [] };
@@ -142,13 +142,15 @@ export default async function HomePage() {
</Reveal> </Reveal>
)} )}
{/* КАТЕГОРИЙНЫЕ РЯДЫ */} {/* КАТЕГОРИЙНЫЕ РЯДЫ — порядок и состав из БД через sort_order */}
{CATEGORY_ORDER.map(cat => ( {categories.map(cat => (
<Reveal key={cat}> (byCategory[cat.slug] || []).length > 0 && (
<div className="reveal"> <Reveal key={cat.slug}>
<CategoryRow category={cat} articles={byCategory[cat] || []} /> <div className="reveal">
</div> <CategoryRow category={cat.slug} articles={byCategory[cat.slug] || []} />
</Reveal> </div>
</Reveal>
)
))} ))}
{/* ПОПУЛЯРНОЕ ЗА МЕСЯЦ */} {/* ПОПУЛЯРНОЕ ЗА МЕСЯЦ */}
+3 -2
View File
@@ -4,7 +4,7 @@ import { usePathname, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { import {
LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink, LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink,
MessageCircle, Clock, Coffee, Menu, X, MessageCircle, Clock, Coffee, Menu, X, FolderPlus,
} from 'lucide-react'; } from 'lucide-react';
// Группы пунктов меню — масштабируется, без давки сверху // Группы пунктов меню — масштабируется, без давки сверху
@@ -18,7 +18,8 @@ const GROUPS = [
{ {
label: 'Контент', label: 'Контент',
items: [ items: [
{ href: '/admin/articles', label: 'Статьи', icon: FileText }, { href: '/admin/categories', label: 'Категории', icon: FolderPlus },
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
{ href: '/admin/drafts', label: 'Черновики', icon: Clock }, { href: '/admin/drafts', label: 'Черновики', icon: Clock },
{ href: '/admin/notes', label: 'Заметки', icon: MessageCircle }, { href: '/admin/notes', label: 'Заметки', icon: MessageCircle },
{ href: '/admin/zero', label: 'Зеро', icon: Coffee }, { href: '/admin/zero', label: 'Зеро', icon: Coffee },
+21 -8
View File
@@ -3,18 +3,21 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Play, Plus, Trash2, RefreshCw, Clock, CheckCircle, XCircle, Zap } from 'lucide-react'; import { Play, Plus, Trash2, RefreshCw, Clock, CheckCircle, XCircle, Zap } from 'lucide-react';
const CAT_LABELS = { // Все цвета палитры — synced с /admin/categories.
'ai-tools': { name: 'ИИ-инструменты', icon: '🤖', color: 'emerald' }, // Хардкода категорий больше нет: они приходят из БД через prop categories.
'cybersec': { name: 'Кибербезопасность', icon: '🔒', color: 'red' },
'automation': { name: 'Автоматизация', icon: '⚡', color: 'amber' },
'ai-dev': { name: 'Разработка с ИИ', icon: '💻', color: 'blue' },
};
const COLOR = { const COLOR = {
emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300', emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300',
red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300', red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300',
amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300', amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300',
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300', blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
purple: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300',
pink: 'bg-pink-50 dark:bg-pink-950 border-pink-200 dark:border-pink-800 text-pink-700 dark:text-pink-300',
cyan: 'bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800 text-cyan-700 dark:text-cyan-300',
orange: 'bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800 text-orange-700 dark:text-orange-300',
lime: 'bg-lime-50 dark:bg-lime-950 border-lime-200 dark:border-lime-800 text-lime-700 dark:text-lime-300',
rose: 'bg-rose-50 dark:bg-rose-950 border-rose-200 dark:border-rose-800 text-rose-700 dark:text-rose-300',
slate: 'bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300',
neutral: 'bg-neutral-50 dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300',
}; };
/** /**
@@ -63,7 +66,17 @@ function fmtNextRun(date) {
}); });
} }
export default function AutogenPanel({ status, queue, topics }) { // catMap превращает массив категорий в lookup-объект по slug → { name, icon, color }
function buildCatMap(categories) {
const map = {};
for (const c of (categories || [])) {
map[c.slug] = { name: c.name, icon: c.icon || '📝', color: c.color || 'emerald' };
}
return map;
}
export default function AutogenPanel({ status, queue, topics, categories = [] }) {
const CAT_LABELS = buildCatMap(categories);
const router = useRouter(); const router = useRouter();
const [running, setRunning] = useState({}); const [running, setRunning] = useState({});
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);