diff --git a/app/api/engine/channels/[channelId]/[[...path]]/route.js b/app/api/engine/channels/[channelId]/[[...path]]/route.js new file mode 100644 index 0000000..28f328b --- /dev/null +++ b/app/api/engine/channels/[channelId]/[[...path]]/route.js @@ -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; diff --git a/components/AutogenTab.js b/components/AutogenTab.js new file mode 100644 index 0000000..5f4cd6a --- /dev/null +++ b/components/AutogenTab.js @@ -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 {g.label}; +} + +// ─── Главный компонент ──────────────────────────────────────────────────────── + +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 ( +
+ {settings?.enabled + ? `Включена · ${postsPerDay} поста/день · ${String(settings.run_hour||10).padStart(2,'0')}:${String(settings.run_minute||0).padStart(2,'0')}` + : 'Выключена — настрой и включи'} +
++ Скользящее окно {postsPerDay} из {categories.filter(c=>c.is_active).length} категорий — каждый день другой набор. +
+