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:
Aleksei Pavlov
2026-06-24 20:05:41 +03:00
parent 69367da665
commit cdd507f1c5
3 changed files with 660 additions and 1 deletions
+602
View File
@@ -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>
);
}
+6 -1
View File
@@ -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} />
)}