3f6cd28798
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
398 lines
18 KiB
JavaScript
398 lines
18 KiB
JavaScript
'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>
|
||
);
|
||
}
|