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,52 @@
|
|||||||
|
/**
|
||||||
|
* Catch-all proxy: /api/engine/channels/:channelId/* → engine :3035/api/channels/:channelId/*
|
||||||
|
*
|
||||||
|
* Используется AutogenTab и CategoryTab для:
|
||||||
|
* - GET/POST/PATCH/DELETE /api/engine/channels/:id/categories
|
||||||
|
* - GET/POST/PATCH/DELETE /api/engine/channels/:id/categories/:catId/topics
|
||||||
|
* - GET/POST/PATCH /api/engine/channels/:id/autogen
|
||||||
|
* - POST /api/engine/channels/:id/autogen/run
|
||||||
|
* - GET /api/engine/channels/:id/autogen/today
|
||||||
|
* - GET /api/engine/channels/:id/autogen/rotation
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const ENGINE = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
|
||||||
|
const SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
|
||||||
|
|
||||||
|
async function proxy(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|
||||||
|
const { channelId, path: pathArr } = await params;
|
||||||
|
const tail = pathArr?.length ? '/' + pathArr.join('/') : '';
|
||||||
|
const qs = req.url.split('?')[1];
|
||||||
|
const url = `${ENGINE}/api/channels/${channelId}${tail}${qs ? '?' + qs : ''}`;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-secret': SECRET,
|
||||||
|
'x-user-id': String(user.id),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
body = await req.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' });
|
||||||
|
const data = await res.json().catch(() => ({ error: 'invalid engine response' }));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[engine proxy] error:', err.message);
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxy;
|
||||||
|
export const POST = proxy;
|
||||||
|
export const PATCH = proxy;
|
||||||
|
export const PUT = proxy;
|
||||||
|
export const DELETE = proxy;
|
||||||
@@ -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 PollModal from './PollModal';
|
||||||
import HashtagSuggest from './HashtagSuggest';
|
import HashtagSuggest from './HashtagSuggest';
|
||||||
import InboxTab from './InboxTab';
|
import InboxTab from './InboxTab';
|
||||||
|
import AutogenTab from './AutogenTab';
|
||||||
|
|
||||||
const GOAL_LABELS = {
|
const GOAL_LABELS = {
|
||||||
educational: 'Обучение', news: 'Новости',
|
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">
|
<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)}
|
<button key={id} onClick={() => setActiveTab(id)}
|
||||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
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'}`}>
|
${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} />
|
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'autogen' && (
|
||||||
|
<AutogenTab channel={channel} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'inbox' && (
|
{activeTab === 'inbox' && (
|
||||||
<InboxTab channel={channel} />
|
<InboxTab channel={channel} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user