From 3f6cd287986de7c8a46237ff8bdf4816f19c2860 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 11:57:35 +0300 Subject: [PATCH] feat(categories): admin section + dynamic categories everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/admin/(protected)/autogen/page.js | 5 +- app/admin/(protected)/categories/page.js | 294 ++++++++++++++++++++ app/admin/api/categories/[...path]/route.js | 40 +++ app/page.js | 18 +- components/admin/AdminNav.js | 5 +- components/admin/AutogenPanel.js | 29 +- 6 files changed, 371 insertions(+), 20 deletions(-) create mode 100644 app/admin/(protected)/categories/page.js create mode 100644 app/admin/api/categories/[...path]/route.js diff --git a/app/admin/(protected)/autogen/page.js b/app/admin/(protected)/autogen/page.js index b07a812..c44e594 100644 --- a/app/admin/(protected)/autogen/page.js +++ b/app/admin/(protected)/autogen/page.js @@ -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 (
- + {/* Блок Зеро — отдельная карточка рядом с категориями статей */}
diff --git a/app/admin/(protected)/categories/page.js b/app/admin/(protected)/categories/page.js new file mode 100644 index 0000000..0d09bf0 --- /dev/null +++ b/app/admin/(protected)/categories/page.js @@ -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 ( +
+ {toast && ( +
{toast.msg}
+ )} + +
+
+

+ Категории +

+

+ Структура контента сайта · отображаются в разделе «Темы» и в автогенерации +

+
+
+ {archivedCount > 0 && ( + + )} + + +
+
+ + {/* EDIT FORM */} + {editing && ( +
+
+

+ {editing === 'new' ? <> Новая категория : <> Редактирование} +

+ +
+ +
+ + 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" /> + + + 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" /> + + + 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" /> + + +