forked from admin/zeropost-tool
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
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Sparkles, LogOut } from 'lucide-react';
|
||||
|
||||
export default function Header({ user }) {
|
||||
const router = useRouter();
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
}
|
||||
return (
|
||||
<header className="border-b border-border bg-bg/80 backdrop-blur sticky top-0 z-10">
|
||||
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2 hover:opacity-80">
|
||||
<Sparkles className="w-5 h-5 text-accent" />
|
||||
<span className="font-bold">ZeroPost</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500 hidden sm:inline">{user?.email}</span>
|
||||
<button onClick={logout} className="btn-ghost p-2" title="Выйти">
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user