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:
@@ -26,17 +26,18 @@ async function engineCall(path) {
|
||||
|
||||
export default async function AutogenPage() {
|
||||
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/queue'),
|
||||
engineCall('/api/autogen/topics'),
|
||||
engineCall('/api/categories'),
|
||||
engineCall('/api/admin/zero/config'),
|
||||
engineCall('/api/admin/zero/notes?limit=5'),
|
||||
]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
||||
@@ -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
@@ -17,7 +17,7 @@ import { Sparkles, ArrowRight } from 'lucide-react';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec'];
|
||||
// CATEGORY_ORDER теперь приходит из БД (categories) — sort_order определяет порядок
|
||||
|
||||
export default async function HomePage() {
|
||||
let home = { hero: null, byCategory: {}, popular: [], recent: [] };
|
||||
@@ -142,13 +142,15 @@ export default async function HomePage() {
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
{/* КАТЕГОРИЙНЫЕ РЯДЫ */}
|
||||
{CATEGORY_ORDER.map(cat => (
|
||||
<Reveal key={cat}>
|
||||
<div className="reveal">
|
||||
<CategoryRow category={cat} articles={byCategory[cat] || []} />
|
||||
</div>
|
||||
</Reveal>
|
||||
{/* КАТЕГОРИЙНЫЕ РЯДЫ — порядок и состав из БД через sort_order */}
|
||||
{categories.map(cat => (
|
||||
(byCategory[cat.slug] || []).length > 0 && (
|
||||
<Reveal key={cat.slug}>
|
||||
<div className="reveal">
|
||||
<CategoryRow category={cat.slug} articles={byCategory[cat.slug] || []} />
|
||||
</div>
|
||||
</Reveal>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* ПОПУЛЯРНОЕ ЗА МЕСЯЦ */}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink,
|
||||
MessageCircle, Clock, Coffee, Menu, X,
|
||||
MessageCircle, Clock, Coffee, Menu, X, FolderPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Группы пунктов меню — масштабируется, без давки сверху
|
||||
@@ -18,7 +18,8 @@ const GROUPS = [
|
||||
{
|
||||
label: 'Контент',
|
||||
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/notes', label: 'Заметки', icon: MessageCircle },
|
||||
{ href: '/admin/zero', label: 'Зеро', icon: Coffee },
|
||||
|
||||
@@ -3,18 +3,21 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Play, Plus, Trash2, RefreshCw, Clock, CheckCircle, XCircle, Zap } from 'lucide-react';
|
||||
|
||||
const CAT_LABELS = {
|
||||
'ai-tools': { name: 'ИИ-инструменты', icon: '🤖', color: 'emerald' },
|
||||
'cybersec': { name: 'Кибербезопасность', icon: '🔒', color: 'red' },
|
||||
'automation': { name: 'Автоматизация', icon: '⚡', color: 'amber' },
|
||||
'ai-dev': { name: 'Разработка с ИИ', icon: '💻', color: 'blue' },
|
||||
};
|
||||
|
||||
// Все цвета палитры — synced с /admin/categories.
|
||||
// Хардкода категорий больше нет: они приходят из БД через prop categories.
|
||||
const COLOR = {
|
||||
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',
|
||||
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',
|
||||
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 [running, setRunning] = useState({});
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
Reference in New Issue
Block a user