Files
postcast-tool/components/ChannelView.js
T
Alexey Pavlov 5dd975a9cd feat: zeropost-tool — Next.js 16 кабинет
- Auth: iron-session, регистрация/логин по email+password
- Дашборд со списком каналов
- 3-шаговая анкета создания канала (база/стиль/примеры+табу)
- Страница канала с генератором постов через polling
- Тёмная тема, Tailwind 3.4, accent emerald
- Прокси-API к zeropost-engine с x-user-id
- Совместимость с Next 16 async cookies/params
2026-05-31 08:38:10 +03:00

151 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings } from 'lucide-react';
const GOAL_LABELS = {
educational: 'Обучение', news: 'Новости',
entertainment: 'Развлечение', expert: 'Экспертный', sales: 'Продажи',
};
export default function ChannelView({ channel }) {
const [topic, setTopic] = useState('');
const [generating, setGenerating] = useState(false);
const [post, setPost] = useState(null);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const [tokens, setTokens] = useState(null);
async function generate() {
if (!topic.trim()) return;
setGenerating(true);
setError('');
setPost(null);
setTokens(null);
try {
const createRes = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'post',
channelId: channel.id,
topic: topic.trim(),
useCritique: true,
}),
});
const job = await createRes.json();
if (!createRes.ok) throw new Error(job.error || 'Ошибка');
// polling
let final;
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 2000));
const r = await fetch(`/api/generate/${job.jobId}`);
const j = await r.json();
if (j.status === 'done' || j.status === 'failed') {
final = j;
break;
}
}
if (!final) throw new Error('Таймаут — попробуй ещё раз');
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out });
} catch (err) {
setError(err.message);
} finally {
setGenerating(false);
}
}
async function copy() {
await navigator.clipboard.writeText(post);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<main className="max-w-4xl mx-auto p-4 sm:p-6">
<Link href="/" className="btn-ghost mb-4 -ml-2">
<ArrowLeft className="w-4 h-4" /> К списку каналов
</Link>
<div className="flex items-start justify-between mb-6 flex-wrap gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-5 h-5 text-accent" />
<h1 className="text-2xl font-bold">{channel.name}</h1>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
{GOAL_LABELS[channel.goal] || channel.goal}
</span>
</div>
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
</div>
<Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
<Settings className="w-4 h-4" />
Настройки
</Link>
</div>
{/* Generator */}
<div className="card p-5 mb-6">
<h2 className="font-semibold mb-3 flex items-center gap-2">
<Wand2 className="w-4 h-4 text-accent" />
Сгенерировать пост
</h2>
<textarea
className="input min-h-[80px] mb-3"
value={topic}
onChange={e => setTopic(e.target.value)}
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
disabled={generating}
/>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-gray-500">
ИИ напишет пост в стиле твоего канала с учётом примеров
</div>
<button onClick={generate} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? (
<><Loader2 className="w-4 h-4 animate-spin" />Генерирую...</>
) : (
<><Wand2 className="w-4 h-4" />Сгенерировать</>
)}
</button>
</div>
{error && (
<div className="mt-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
</div>
{/* Result */}
{post && (
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">Результат</h3>
<div className="flex items-center gap-3">
{tokens && (
<span className="text-xs text-gray-500">
{tokens.in}/{tokens.out} токенов
</span>
)}
<button onClick={copy} className="btn-ghost text-sm py-1">
{copied ? (
<><Check className="w-4 h-4 text-accent" /> Скопировано</>
) : (
<><Copy className="w-4 h-4" /> Копировать</>
)}
</button>
</div>
</div>
<div className="bg-surface2 border border-border rounded-lg p-4 whitespace-pre-wrap text-sm leading-relaxed">
{post}
</div>
</div>
)}
</main>
);
}