Files
zeropost-web/components/admin/AutogenPanel.js
T
Aleksei Pavlov 3f6cd28798 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
2026-06-19 11:57:35 +03:00

398 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Play, Plus, Trash2, RefreshCw, Clock, CheckCircle, XCircle, Zap } from 'lucide-react';
// Все цвета палитры — 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',
};
/**
* Рассчитать реальное время следующего запуска автогенерации.
*
* Логика автогена (см. src/services/autogen.js):
* - cron бьёт каждые 10 минут
* - срабатывает если: run_hour=ТЕКУЩИЙ_ЧАС_MSK + run_minute в окне ±5
* - + защита: last_run_at < NOW() - INTERVAL '6 hours'
*
* Это даёт: следующий запуск — ближайший момент {run_hour:run_minute} MSK
* после max(now, last_run_at + 6h).
*/
function calcNextRun({ run_hour = 8, run_minute = 0, last_run_at, enabled }) {
if (!enabled) return null;
const now = Date.now();
// Сегодняшняя дата в MSK
const mskNow = new Date(now + 3 * 3600 * 1000);
const y = mskNow.getUTCFullYear();
const m = mskNow.getUTCMonth();
const d = mskNow.getUTCDate();
// Целевое UTC время для сегодняшнего run_hour:run_minute в MSK
// (MSK = UTC+3, поэтому UTC = MSK - 3h)
let target = Date.UTC(y, m, d, run_hour, run_minute) - 3 * 3600 * 1000;
// Если уже прошло (с окном +5 мин) — переносим на завтра
if (target + 5 * 60 * 1000 < now) {
target += 24 * 3600 * 1000;
}
// Защита «не чаще раза в 6 часов»
if (last_run_at) {
const guard = new Date(last_run_at).getTime() + 6 * 3600 * 1000;
while (target < guard) target += 24 * 3600 * 1000;
}
return new Date(target);
}
function fmtNextRun(date) {
if (!date) return '—';
return date.toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
});
}
// 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);
const [newTopic, setNewTopic] = useState('');
const [newCat, setNewCat] = useState('ai-tools');
const [addingTopic, setAddingTopic] = useState(false);
const [showAddForm, setShowAddForm] = useState(false);
function showToast(msg, type = 'success') {
setToast({ msg, type });
setTimeout(() => setToast(null), 4000);
}
async function runCategory(category) {
setRunning(r => ({ ...r, [category]: true }));
try {
const res = await fetch('/admin/api/autogen/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category }),
});
if (!res.ok) throw new Error(await res.text());
showToast(`Генерация запущена для "${CAT_LABELS[category]?.name}". Статья появится через ~2 мин.`);
} catch (e) {
showToast(e.message, 'error');
} finally {
setTimeout(() => {
setRunning(r => ({ ...r, [category]: false }));
router.refresh();
}, 5000);
}
}
async function runAll() {
setRunning({ all: true });
try {
const res = await fetch('/admin/api/autogen/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(await res.text());
showToast('Запущена генерация для всех активных категорий');
} catch (e) {
showToast(e.message, 'error');
} finally {
setTimeout(() => { setRunning({}); router.refresh(); }, 8000);
}
}
async function updateTime(category, run_hour, run_minute) {
await fetch('/admin/api/autogen/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, run_hour: parseInt(run_hour), run_minute: parseInt(run_minute) }),
});
router.refresh();
}
async function toggleCategory(category, enabled) {
await fetch('/admin/api/autogen/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, enabled }),
});
router.refresh();
}
async function updatePerDay(category, per_day) {
await fetch('/admin/api/autogen/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category, per_day: parseInt(per_day) }),
});
router.refresh();
}
async function addTopic() {
if (!newTopic.trim()) return;
setAddingTopic(true);
try {
const res = await fetch('/admin/api/autogen/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: newCat, topic: newTopic.trim(), priority: 8 }),
});
if (!res.ok) throw new Error(await res.text());
showToast('Тема добавлена в очередь');
setNewTopic('');
setShowAddForm(false);
router.refresh();
} catch (e) {
showToast(e.message, 'error');
} finally {
setAddingTopic(false);
}
}
async function removeTopic(id) {
await fetch(`/admin/api/autogen/queue/${id}`, { method: 'DELETE' });
router.refresh();
}
const pendingQueue = queue.filter(q => q.status === 'pending');
return (
<div className="space-y-8">
{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">Автогенерация</h1>
<p className="text-sm text-neutral-500 mt-0.5">Автоматическое создание статей по расписанию (MSK)</p>
</div>
<button
onClick={runAll}
disabled={!!running.all}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{running.all ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{running.all ? 'Запускается...' : 'Запустить все'}
</button>
</div>
<div className="grid sm:grid-cols-2 gap-4">
{status.map(s => {
const cat = CAT_LABELS[s.category] || { name: s.category, icon: '📝', color: 'emerald' };
const colorCls = COLOR[cat.color] || COLOR.emerald;
const isRunning = running[s.category] || running.all;
const nextRun = calcNextRun(s);
return (
<div key={s.category} className={`rounded-xl border p-5 ${colorCls}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{cat.icon}</span>
<div>
<div className="font-semibold text-sm">{cat.name}</div>
<div className="text-xs opacity-70">{s.article_count || 0} статей · {s.queue_count || 0} в очереди</div>
</div>
</div>
<button
onClick={() => toggleCategory(s.category, !s.enabled)}
className={`text-xs px-2 py-1 rounded-full font-medium border transition-colors ${
s.enabled ? 'bg-white dark:bg-neutral-900 border-current' : 'opacity-50 border-current'
}`}
>
{s.enabled ? '● Вкл' : '○ Выкл'}
</button>
</div>
{/* Расписание */}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-3">
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
<span className="text-xs opacity-70">Статей в день:</span>
<select value={s.per_day} onChange={e => updatePerDay(s.category, e.target.value)}
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5">
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-center gap-3 flex-wrap">
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
<span className="text-xs opacity-70">Время (MSK):</span>
<div className="inline-flex items-center gap-1 bg-white/50 dark:bg-black/20 border border-current/20 rounded px-1.5 py-0.5">
<select
value={s.run_hour ?? 8}
onChange={e => updateTime(s.category, e.target.value, s.run_minute ?? 0)}
className="text-xs bg-transparent font-mono focus:outline-none"
>
{Array.from({length: 24}, (_,i) => (
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
))}
</select>
<span className="text-xs opacity-70 font-mono">:</span>
<select
value={s.run_minute ?? 0}
onChange={e => updateTime(s.category, s.run_hour ?? 8, e.target.value)}
className="text-xs bg-transparent font-mono focus:outline-none"
>
{[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
<option key={m} value={m}>{String(m).padStart(2,'0')}</option>
))}
</select>
</div>
</div>
<div className="text-xs opacity-60 pl-6">
Следующий запуск: <span className="font-medium">{fmtNextRun(nextRun)}</span>
{s.last_run_at && (
<> · последний: {new Date(s.last_run_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</>
)}
</div>
</div>
<button
onClick={() => runCategory(s.category)}
disabled={isRunning || !s.enabled}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-white/60 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/30 disabled:opacity-40 text-sm font-medium transition-colors border border-current/20"
>
{isRunning ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{isRunning ? 'Генерируется...' : 'Сгенерировать сейчас'}
</button>
</div>
);
})}
</div>
{/* Очередь тем */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
Очередь тем <span className="text-neutral-400 font-normal">({pendingQueue.length})</span>
</h2>
<button
onClick={() => setShowAddForm(v => !v)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Добавить тему
</button>
</div>
{showAddForm && (
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<div className="flex gap-3">
<select
value={newCat}
onChange={e => setNewCat(e.target.value)}
className="px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 shrink-0"
>
{Object.entries(CAT_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l.icon} {l.name}</option>
))}
</select>
<input
type="text"
value={newTopic}
onChange={e => setNewTopic(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTopic()}
placeholder="Тема статьи..."
className="flex-1 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
autoFocus
/>
<button
onClick={addTopic}
disabled={addingTopic || !newTopic.trim()}
className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors shrink-0"
>
{addingTopic ? '...' : 'Добавить'}
</button>
</div>
</div>
)}
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{pendingQueue.length === 0 && !showAddForm && (
<div className="px-5 py-8 text-center text-sm text-neutral-400">
Очередь пуста темы берутся из банка автоматически
</div>
)}
{pendingQueue.map(item => {
const cat = CAT_LABELS[item.category];
return (
<div key={item.id} className="flex items-center gap-3 px-5 py-3">
<span className="text-lg shrink-0">{cat?.icon || '📝'}</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">{item.topic}</div>
<div className="text-xs text-neutral-400">{cat?.name} · приоритет {item.priority}</div>
</div>
<button onClick={() => removeTopic(item.id)} className="p-1.5 rounded text-neutral-300 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
</div>
{/* Банк тем */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Банк тем</h2>
<p className="text-xs text-neutral-400 mt-0.5">Темы из которых AI выбирает автоматически когда очередь пуста</p>
</div>
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{Object.entries(topics).map(([catSlug, topicList]) => {
const cat = CAT_LABELS[catSlug];
return (
<div key={catSlug} className="px-5 py-4">
<div className="flex items-center gap-2 mb-2">
<span>{cat?.icon}</span>
<span className="text-xs font-semibold text-neutral-500 uppercase tracking-wide">{cat?.name}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{topicList.map((t, i) => (
<button
key={i}
onClick={() => { setNewCat(catSlug); setNewTopic(t); setShowAddForm(true); }}
className="text-xs px-2.5 py-1 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-emerald-50 dark:hover:bg-emerald-950 hover:text-emerald-600 transition-colors"
>
{t}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}