forked from admin/zeropost-tool
feat(postcast-tool): AutogenTab — категории, темы, ротация
Новая вкладка «Автогенерация» в ChannelView:
Настройки:
- Включить/выключить автогенерацию
- posts_per_day: 1-20 (каждый пользователь настраивает сам)
- Час и минута запуска генерации
Планируется сегодня:
- Черновики сегодняшней генерации с кнопкой «Открыть»
- Пустое состояние с временем следующей генерации
Категории контента:
- Список с бейджами «сегодня» / «не сегодня» (ротация)
- Форма создания: иконка, название, slug, описание, цвет
- Каждая категория раскрывается с панелью тем:
· Список тем с жанровыми бейджами [ТУТОРИАЛ][СРАВНЕНИЕ][МНЕНИЕ][ДАЙДЖЕСТ][КЕЙС]
· Toggle is_used (✓ / ○)
· Добавить тему вручную (Enter или кнопка)
· AI-генерация N тем (5/10/15/20/30/50)
· Удалить тему
Ротация на 7 дней:
- Preview скользящего окна — видно что выйдет в каждый день
- Подпись «X из Y категорий» с объяснением алгоритма
API proxy:
/api/engine/channels/:channelId/[[...path]] — catch-all к engine :3035
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Sparkles, RefreshCw, Loader2, Trash2, Edit3, Save, X,
|
||||
ChevronDown, ChevronRight, Play, Settings, Calendar, Clock,
|
||||
BookOpen, Zap, Tag, RotateCcw, Check, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ─── Константы ────────────────────────────────────────────────────────────────
|
||||
|
||||
const COLORS = [
|
||||
{ key: 'indigo', cls: 'bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800 text-indigo-700 dark:text-indigo-300' },
|
||||
{ key: 'emerald', cls: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300' },
|
||||
{ key: 'amber', cls: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300' },
|
||||
{ key: 'red', cls: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' },
|
||||
{ key: 'purple', cls: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300' },
|
||||
{ key: 'blue', cls: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300' },
|
||||
{ key: 'cyan', cls: 'bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800 text-cyan-700 dark:text-cyan-300' },
|
||||
{ key: 'orange', cls: 'bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800 text-orange-700 dark:text-orange-300' },
|
||||
{ key: 'rose', cls: 'bg-rose-50 dark:bg-rose-950 border-rose-200 dark:border-rose-800 text-rose-700 dark:text-rose-300' },
|
||||
{ key: 'neutral', cls: 'bg-neutral-50 dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300' },
|
||||
];
|
||||
const colorCls = (k) => (COLORS.find(c => c.key === k) || COLORS[0]).cls;
|
||||
|
||||
const GENRE_LABELS = {
|
||||
tutorial: { label: 'Туториал', color: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300' },
|
||||
comparison: { label: 'Сравнение', color: 'bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300' },
|
||||
opinion: { label: 'Мнение', color: 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300' },
|
||||
digest: { label: 'Дайджест', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' },
|
||||
case: { label: 'Кейс', color: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300' },
|
||||
news: { label: 'Новость', color: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950 dark:text-cyan-300' },
|
||||
};
|
||||
|
||||
function detectGenre(topic) {
|
||||
const m = topic?.match(/^\[([^\]]+)\]/);
|
||||
if (!m) return null;
|
||||
const map = { 'ТУТОРИАЛ':'tutorial','СРАВНЕНИЕ':'comparison','МНЕНИЕ':'opinion','ДАЙДЖЕСТ':'digest','КЕЙС':'case','НОВОСТЬ':'news' };
|
||||
return map[m[1].toUpperCase()] || null;
|
||||
}
|
||||
|
||||
function GenreBadge({ genre }) {
|
||||
if (!genre) return null;
|
||||
const g = GENRE_LABELS[genre];
|
||||
if (!g) return null;
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded font-semibold shrink-0 ${g.color}`}>{g.label}</span>;
|
||||
}
|
||||
|
||||
// ─── Главный компонент ────────────────────────────────────────────────────────
|
||||
|
||||
export default function AutogenTab({ channel }) {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [todayDrafts, setTodayDrafts] = useState([]);
|
||||
const [rotation, setRotation] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
// Состояние создания категории
|
||||
const [showNewCat, setShowNewCat] = useState(false);
|
||||
const [newCat, setNewCat] = useState({ slug:'', name:'', icon:'📝', color:'indigo', description:'' });
|
||||
const [savingCat, setSavingCat] = useState(false);
|
||||
|
||||
// Развёрнутая категория (темы)
|
||||
const [expandedCatId, setExpandedCatId] = useState(null);
|
||||
|
||||
// Настройки autogen
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [autogenForm, setAutogenForm] = useState({ enabled: false, posts_per_day: 3, run_hour: 10, run_minute: 0 });
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
|
||||
// Запуск генерации
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const flash = (msg, type='success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); };
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusRes, catsRes, draftsRes, rotRes] = await Promise.allSettled([
|
||||
fetch(`/api/engine/channels/${channel.id}/autogen`),
|
||||
fetch(`/api/engine/channels/${channel.id}/categories`),
|
||||
fetch(`/api/engine/channels/${channel.id}/autogen/today`),
|
||||
fetch(`/api/engine/channels/${channel.id}/autogen/rotation?days=7`),
|
||||
]);
|
||||
if (statusRes.status === 'fulfilled' && statusRes.value.ok) {
|
||||
const d = await statusRes.value.json();
|
||||
setSettings(d);
|
||||
setAutogenForm({ enabled: !!d.enabled, posts_per_day: d.posts_per_day||3, run_hour: d.run_hour||10, run_minute: d.run_minute||0 });
|
||||
}
|
||||
if (catsRes.status === 'fulfilled' && catsRes.value.ok) {
|
||||
const d = await catsRes.value.json();
|
||||
setCategories(d.items || []);
|
||||
}
|
||||
if (draftsRes.status === 'fulfilled' && draftsRes.value.ok) {
|
||||
const d = await draftsRes.value.json();
|
||||
setTodayDrafts(d.drafts || []);
|
||||
}
|
||||
if (rotRes.status === 'fulfilled' && rotRes.value.ok) {
|
||||
const d = await rotRes.value.json();
|
||||
setRotation(d.preview || []);
|
||||
}
|
||||
} catch (e) { flash('Ошибка загрузки: ' + e.message, 'error'); }
|
||||
setLoading(false);
|
||||
}, [channel.id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// ── Сохранить настройки autogen ──────────────────────────────────────────
|
||||
async function saveAutogenSettings() {
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
const r = await fetch(`/api/engine/channels/${channel.id}/autogen/enable`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(autogenForm),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'fail');
|
||||
flash('Настройки сохранены');
|
||||
setShowSettings(false);
|
||||
await load();
|
||||
} catch (e) { flash(e.message, 'error'); }
|
||||
setSavingSettings(false);
|
||||
}
|
||||
|
||||
// ── Создать категорию ─────────────────────────────────────────────────────
|
||||
async function createCategory() {
|
||||
if (!newCat.name.trim() || !newCat.slug.trim()) return;
|
||||
setSavingCat(true);
|
||||
try {
|
||||
const r = await fetch(`/api/engine/channels/${channel.id}/categories`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newCat),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || 'fail');
|
||||
flash(`Категория «${newCat.name}» создана`);
|
||||
setNewCat({ slug:'', name:'', icon:'📝', color:'indigo', description:'' });
|
||||
setShowNewCat(false);
|
||||
await load();
|
||||
} catch (e) { flash(e.message, 'error'); }
|
||||
setSavingCat(false);
|
||||
}
|
||||
|
||||
// ── Запустить генерацию вручную ────────────────────────────────────────────
|
||||
async function runGeneration(categoryId = null) {
|
||||
setRunning(true);
|
||||
try {
|
||||
const r = await fetch(`/api/engine/channels/${channel.id}/autogen/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryId ? { category_id: categoryId } : {}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'fail');
|
||||
flash('Генерация запущена — черновики появятся через ~30 сек');
|
||||
setTimeout(() => load(), 35000);
|
||||
} catch (e) { flash(e.message, 'error'); }
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="py-16 text-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto" style={{ color: 'rgb(var(--accent))' }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const todaySet = new Set(settings?.categories?.filter(c => c.today_active).map(c => c.id) || []);
|
||||
const postsPerDay = settings?.posts_per_day || autogenForm.posts_per_day || 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 relative">
|
||||
{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="card p-5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base flex items-center gap-2" style={{ color: 'rgb(var(--text))' }}>
|
||||
<Zap className="w-4 h-4" style={{ color: 'rgb(var(--accent))' }} />
|
||||
Автогенерация
|
||||
</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'rgb(var(--text-soft))' }}>
|
||||
{settings?.enabled
|
||||
? `Включена · ${postsPerDay} поста/день · ${String(settings.run_hour||10).padStart(2,'0')}:${String(settings.run_minute||0).padStart(2,'0')}`
|
||||
: 'Выключена — настрой и включи'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setShowSettings(v => !v)} className="btn-ghost text-sm">
|
||||
<Settings className="w-4 h-4" /> Настройки
|
||||
</button>
|
||||
<button onClick={() => runGeneration()} disabled={running || !settings?.enabled}
|
||||
className="btn-primary text-sm">
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
Сгенерировать сейчас
|
||||
</button>
|
||||
<button onClick={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Форма настроек */}
|
||||
{showSettings && (
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: 'rgb(var(--border))' }}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium" style={{ color: 'rgb(var(--text-soft))' }}>Постов в день</span>
|
||||
<input type="number" min={1} max={20} value={autogenForm.posts_per_day}
|
||||
onChange={e => setAutogenForm(f => ({ ...f, posts_per_day: parseInt(e.target.value,10)||3 }))}
|
||||
className="input text-sm" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium" style={{ color: 'rgb(var(--text-soft))' }}>Час генерации (МСК)</span>
|
||||
<input type="number" min={0} max={23} value={autogenForm.run_hour}
|
||||
onChange={e => setAutogenForm(f => ({ ...f, run_hour: parseInt(e.target.value,10)||10 }))}
|
||||
className="input text-sm" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium" style={{ color: 'rgb(var(--text-soft))' }}>Минута</span>
|
||||
<input type="number" min={0} max={59} value={autogenForm.run_minute}
|
||||
onChange={e => setAutogenForm(f => ({ ...f, run_minute: parseInt(e.target.value,10)||0 }))}
|
||||
className="input text-sm" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium" style={{ color: 'rgb(var(--text-soft))' }}>Статус</span>
|
||||
<button onClick={() => setAutogenForm(f => ({ ...f, enabled: !f.enabled }))}
|
||||
className={`text-sm font-medium rounded-lg px-3 py-2 transition-colors ${
|
||||
autogenForm.enabled ? 'bg-emerald-500 text-white' : 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400'
|
||||
}`}>
|
||||
{autogenForm.enabled ? '● Включена' : '○ Выключена'}
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={saveAutogenSettings} disabled={savingSettings} className="btn-primary text-sm">
|
||||
{savingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={() => setShowSettings(false)} className="btn-ghost text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Планируется сегодня ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2" style={{ color: 'rgb(var(--text))' }}>
|
||||
<Clock className="w-4 h-4" style={{ color: 'rgb(var(--accent))' }} />
|
||||
Планируется сегодня
|
||||
</h3>
|
||||
<span className="text-xs" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
{todayDrafts.length > 0 ? `${todayDrafts.length} из ${postsPerDay} готово` : `Генерация в ${String(settings?.run_hour||10).padStart(2,'0')}:${String(settings?.run_minute||0).padStart(2,'0')}`}
|
||||
</span>
|
||||
</div>
|
||||
{todayDrafts.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
<BookOpen className="w-6 h-6 mx-auto mb-2 opacity-50" />
|
||||
Черновики появятся после генерации
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{todayDrafts.map(d => (
|
||||
<div key={d.id} className="flex items-center gap-3 py-2 px-3 rounded-lg"
|
||||
style={{ background: 'rgb(var(--surface2))' }}>
|
||||
<span className="text-lg shrink-0">{d.category_icon || '📝'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate" style={{ color: 'rgb(var(--text))' }}>
|
||||
{d.content?.split('\n')[0]?.slice(0,70) || '...'}
|
||||
</div>
|
||||
<div className="text-xs flex items-center gap-2 mt-0.5" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
{d.category_name && <span>{d.category_name}</span>}
|
||||
{d.genre && <GenreBadge genre={d.genre} />}
|
||||
</div>
|
||||
</div>
|
||||
<a href={`/channels/${channel.id}/drafts/${d.id}`}
|
||||
className="text-xs btn-ghost px-2.5 py-1.5 shrink-0">
|
||||
Открыть
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Категории ── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b"
|
||||
style={{ borderColor: 'rgb(var(--border))' }}>
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2" style={{ color: 'rgb(var(--text))' }}>
|
||||
<Tag className="w-4 h-4" style={{ color: 'rgb(var(--accent))' }} />
|
||||
Категории контента
|
||||
<span className="text-xs font-normal" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
({categories.filter(c => c.is_active).length} активных)
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={() => setShowNewCat(v => !v)} className="btn-ghost text-sm">
|
||||
<Plus className="w-4 h-4" /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Форма новой категории */}
|
||||
{showNewCat && (
|
||||
<div className="px-5 py-4 border-b" style={{ borderColor: 'rgb(var(--border))', background: 'rgb(var(--surface2))' }}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs mb-1" style={{ color: 'rgb(var(--text-soft))' }}>Иконка</label>
|
||||
<input value={newCat.icon} onChange={e => setNewCat(f => ({ ...f, icon: e.target.value }))}
|
||||
maxLength={4} className="input text-center text-2xl w-full py-1.5" />
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-xs mb-1" style={{ color: 'rgb(var(--text-soft))' }}>Название</label>
|
||||
<input value={newCat.name} onChange={e => setNewCat(f => ({
|
||||
...f,
|
||||
name: e.target.value,
|
||||
slug: f.slug || e.target.value.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''),
|
||||
}))} className="input text-sm" placeholder="Туториалы" />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-xs mb-1" style={{ color: 'rgb(var(--text-soft))' }}>Slug (URL)</label>
|
||||
<input value={newCat.slug} onChange={e => setNewCat(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g,'') }))}
|
||||
className="input text-sm font-mono" placeholder="tutorials" />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-xs mb-1" style={{ color: 'rgb(var(--text-soft))' }}>Цвет</label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COLORS.map(c => (
|
||||
<button key={c.key} onClick={() => setNewCat(f => ({ ...f, color: c.key }))}
|
||||
className={`w-6 h-6 rounded-full border-2 transition-all ${
|
||||
newCat.color === c.key ? 'ring-2 ring-offset-1 ring-indigo-500 border-indigo-500' : 'border-transparent'
|
||||
}`}
|
||||
style={{ background: `var(--tw-gradient-stops)` }}
|
||||
title={c.key}
|
||||
>
|
||||
<span className={`block w-full h-full rounded-full ${c.cls.split(' ')[0]}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-12">
|
||||
<label className="block text-xs mb-1" style={{ color: 'rgb(var(--text-soft))' }}>Описание (для AI-промпта)</label>
|
||||
<input value={newCat.description} onChange={e => setNewCat(f => ({ ...f, description: e.target.value }))}
|
||||
className="input text-sm w-full" placeholder="Пошаговые гайды по AI-инструментам" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button onClick={createCategory} disabled={savingCat || !newCat.name.trim() || !newCat.slug.trim()} className="btn-primary text-sm">
|
||||
{savingCat ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Создать
|
||||
</button>
|
||||
<button onClick={() => setShowNewCat(false)} className="btn-ghost text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список категорий */}
|
||||
<div className="divide-y" style={{ borderColor: 'rgb(var(--border))' }}>
|
||||
{categories.length === 0 && (
|
||||
<div className="px-5 py-8 text-center text-sm" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
Категорий нет — добавь первую
|
||||
</div>
|
||||
)}
|
||||
{categories.map(cat => (
|
||||
<CategoryRow
|
||||
key={cat.id}
|
||||
cat={cat}
|
||||
channelId={channel.id}
|
||||
todayActive={todaySet.has(cat.id)}
|
||||
expanded={expandedCatId === cat.id}
|
||||
onToggle={() => setExpandedCatId(expandedCatId === cat.id ? null : cat.id)}
|
||||
onRunNow={() => runGeneration(cat.id)}
|
||||
running={running}
|
||||
flash={flash}
|
||||
onRefresh={load}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Ротация на 7 дней ── */}
|
||||
{rotation.length > 0 && (
|
||||
<div className="card p-5">
|
||||
<h3 className="font-semibold text-sm flex items-center gap-2 mb-3" style={{ color: 'rgb(var(--text))' }}>
|
||||
<RotateCcw className="w-4 h-4" style={{ color: 'rgb(var(--accent))' }} />
|
||||
Ротация категорий на 7 дней
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{rotation.map((day, i) => {
|
||||
const isToday = i === 0;
|
||||
return (
|
||||
<div key={day.date} className={`flex items-center gap-3 py-2 px-3 rounded-lg text-sm ${isToday ? 'ring-1' : ''}`}
|
||||
style={isToday ? { background:'rgb(var(--accent)/0.08)', borderColor:'rgb(var(--accent)/0.3)' } : { background: 'rgb(var(--surface2))' }}>
|
||||
<span className="text-xs w-16 shrink-0 font-mono" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
{isToday ? 'Сегодня' : new Date(day.date+'T12:00:00').toLocaleDateString('ru-RU',{day:'numeric',month:'short'})}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{day.categories.map(c => (
|
||||
<span key={c.id} className={`text-xs px-2 py-0.5 rounded-full border font-medium ${colorCls(c.color||'neutral')}`}>
|
||||
{c.icon} {c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs mt-3" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
Скользящее окно {postsPerDay} из {categories.filter(c=>c.is_active).length} категорий — каждый день другой набор.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CategoryRow ───────────────────────────────────────────────────────────────
|
||||
|
||||
function CategoryRow({ cat, channelId, todayActive, expanded, onToggle, onRunNow, running, flash, onRefresh }) {
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [loadingTopics, setLoadingTopics] = useState(false);
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [addingTopic, setAddingTopic] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [genCount, setGenCount] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded && topics.length === 0) loadTopics();
|
||||
}, [expanded]);
|
||||
|
||||
async function loadTopics() {
|
||||
setLoadingTopics(true);
|
||||
try {
|
||||
const r = await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics?free=false`);
|
||||
const d = await r.json();
|
||||
setTopics(d.topics || []);
|
||||
} catch {}
|
||||
setLoadingTopics(false);
|
||||
}
|
||||
|
||||
async function addTopic() {
|
||||
if (!newTopic.trim()) return;
|
||||
setAddingTopic(true);
|
||||
try {
|
||||
const r = await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic: newTopic.trim() }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error);
|
||||
setNewTopic('');
|
||||
await loadTopics();
|
||||
onRefresh();
|
||||
flash('Тема добавлена');
|
||||
} catch (e) { flash(e.message, 'error'); }
|
||||
setAddingTopic(false);
|
||||
}
|
||||
|
||||
async function generateTopics() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: genCount }),
|
||||
});
|
||||
flash(`AI генерирует ${genCount} тем — появятся через ~15 сек`);
|
||||
setTimeout(() => { loadTopics(); onRefresh(); }, 18000);
|
||||
} catch (e) { flash(e.message, 'error'); }
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
async function deleteTopic(id) {
|
||||
await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/${id}`, { method: 'DELETE' });
|
||||
setTopics(ts => ts.filter(t => t.id !== id));
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
async function toggleUsed(t) {
|
||||
await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/${t.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_used: !t.is_used }),
|
||||
});
|
||||
await loadTopics();
|
||||
onRefresh();
|
||||
}
|
||||
|
||||
const freeCount = topics.filter(t => !t.is_used).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Заголовок строки */}
|
||||
<div className={`flex items-center gap-3 px-5 py-3.5 transition-opacity ${!cat.is_active ? 'opacity-40' : ''}`}>
|
||||
<span className="text-xl shrink-0">{cat.icon || '📝'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm" style={{ color: 'rgb(var(--text))' }}>{cat.name}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded font-mono" style={{ background:'rgb(var(--surface2))', color:'rgb(var(--text-mute))' }}>
|
||||
{cat.slug}
|
||||
</span>
|
||||
{todayActive
|
||||
? <span className="text-[10px] px-1.5 py-0.5 rounded font-semibold bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400">сегодня</span>
|
||||
: <span className="text-[10px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-500">не сегодня</span>
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5 flex items-center gap-2" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
<button onClick={onToggle} className="hover:underline">
|
||||
{cat.topics_free ?? 0} тем свободно · {cat.topics_total ?? 0} всего
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{todayActive && (
|
||||
<button onClick={onRunNow} disabled={running}
|
||||
className="text-xs btn-ghost px-2 py-1.5 gap-1">
|
||||
{running ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
Генерировать
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onToggle} className="btn-ghost p-1.5">
|
||||
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Раскрытая панель тем */}
|
||||
{expanded && (
|
||||
<div className="px-5 pb-4 border-t" style={{ borderColor: 'rgb(var(--border))', background: 'rgb(var(--surface2))' }}>
|
||||
{/* AI генерация */}
|
||||
<div className="flex items-center gap-2 mt-3 mb-3">
|
||||
<button onClick={generateTopics} disabled={generating}
|
||||
className="btn-primary text-xs flex-1">
|
||||
{generating ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
|
||||
{generating ? 'AI генерирует...' : `Сгенерировать ${genCount} тем`}
|
||||
</button>
|
||||
<select value={genCount} onChange={e => setGenCount(parseInt(e.target.value,10))}
|
||||
className="input text-xs py-1.5 w-20">
|
||||
{[5,10,15,20,30,50].map(n => <option key={n} value={n}>{n} тем</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Добавить тему вручную */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input value={newTopic} onChange={e => setNewTopic(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addTopic()}
|
||||
placeholder="Новая тема... ([ТУТОРИАЛ] Как сделать X)"
|
||||
className="input text-sm flex-1" />
|
||||
<button onClick={addTopic} disabled={addingTopic || !newTopic.trim()} className="btn-primary px-3">
|
||||
{addingTopic ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Список тем */}
|
||||
{loadingTopics ? (
|
||||
<div className="py-3 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto opacity-50" /></div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{topics.length === 0 && (
|
||||
<div className="text-xs text-center py-3" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
Тем нет — добавь или сгенерируй
|
||||
</div>
|
||||
)}
|
||||
{topics.map(t => {
|
||||
const genre = t.genre || detectGenre(t.topic);
|
||||
const cleanTopic = t.topic.replace(/^\[[^\]]+\]\s*/,'');
|
||||
return (
|
||||
<div key={t.id}
|
||||
className={`flex items-center gap-2 py-1.5 px-2 rounded text-xs group ${t.is_used ? 'opacity-40' : ''}`}>
|
||||
<button onClick={() => toggleUsed(t)} className="shrink-0" title={t.is_used ? 'Вернуть в банк' : 'Пометить использованной'}>
|
||||
{t.is_used
|
||||
? <Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
: <div className="w-3.5 h-3.5 rounded-full border border-current opacity-30" />
|
||||
}
|
||||
</button>
|
||||
{genre && <GenreBadge genre={genre} />}
|
||||
<span className="flex-1 truncate" style={{ color: 'rgb(var(--text))' }} title={t.topic}>
|
||||
{cleanTopic}
|
||||
</span>
|
||||
<button onClick={() => deleteTopic(t.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 rounded text-red-400 hover:text-red-600 transition-opacity">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs mt-2" style={{ color: 'rgb(var(--text-mute))' }}>
|
||||
{freeCount} свободных · {topics.length - freeCount} использованных
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import FromUrlModal from './FromUrlModal';
|
||||
import PollModal from './PollModal';
|
||||
import HashtagSuggest from './HashtagSuggest';
|
||||
import InboxTab from './InboxTab';
|
||||
import AutogenTab from './AutogenTab';
|
||||
|
||||
const GOAL_LABELS = {
|
||||
educational: 'Обучение', news: 'Новости',
|
||||
@@ -364,7 +365,7 @@ export default function ChannelView({ channel }) {
|
||||
|
||||
{/* Вкладки */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
|
||||
{[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
|
||||
{[['generate','Создать пост'],['autogen','Автогенерация'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
|
||||
<button key={id} onClick={() => setActiveTab(id)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
|
||||
@@ -377,6 +378,10 @@ export default function ChannelView({ channel }) {
|
||||
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
||||
)}
|
||||
|
||||
{activeTab === 'autogen' && (
|
||||
<AutogenTab channel={channel} />
|
||||
)}
|
||||
|
||||
{activeTab === 'inbox' && (
|
||||
<InboxTab channel={channel} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user