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:
Alexey Pavlov
2026-05-31 08:38:10 +03:00
parent 8e979c3045
commit 5dd975a9cd
26 changed files with 3334 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
import { notFound, redirect } from 'next/navigation';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
import Header from '@/components/Header';
import ChannelView from '@/components/ChannelView';
export default async function ChannelPage({ params }) {
const user = await requireUser();
if (!user) redirect('/login');
const { id } = await params;
let channel;
try {
channel = await engine.getChannel(user.id, id);
} catch {
notFound();
}
if (!channel) notFound();
return (
<>
<Header user={user} />
<ChannelView channel={channel} />
</>
);
}
+344
View File
@@ -0,0 +1,344 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, X, Plus, Sparkles } from 'lucide-react';
const GOALS = [
{ v: 'educational', label: 'Обучение', desc: 'Объясняем, разбираем' },
{ v: 'news', label: 'Новости', desc: 'Что произошло' },
{ v: 'entertainment', label: 'Развлечение', desc: 'Лёгкий контент, мемы' },
{ v: 'expert', label: 'Экспертный', desc: 'Глубокий анализ, инсайты' },
{ v: 'sales', label: 'Продажи', desc: 'Подвести к покупке' },
];
const TONES = [
{ v: 'friendly', label: 'Дружелюбный' },
{ v: 'serious', label: 'Серьёзный' },
{ v: 'ironic', label: 'Ироничный' },
{ v: 'provocative', label: 'Провокационный' },
{ v: 'academic', label: 'Академичный' },
];
const LENGTHS = [
{ v: 'short', label: 'Короткий', desc: 'до 300 знаков' },
{ v: 'medium', label: 'Средний', desc: '300-800' },
{ v: 'long', label: 'Длинный', desc: '800-2000' },
];
const EMOJI = [
{ v: 'none', label: 'Без эмодзи' },
{ v: 'moderate', label: 'Умеренно' },
{ v: 'active', label: 'Активно' },
];
const HUMOR = [
{ v: 'none', label: 'Без юмора' },
{ v: 'dry', label: 'Сухой/ирония' },
{ v: 'moderate', label: 'Умеренный' },
{ v: 'playful', label: 'Игривый' },
];
export default function NewChannelPage() {
const router = useRouter();
const [step, setStep] = useState(1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
// Шаг 1 — база
const [name, setName] = useState('');
const [niche, setNiche] = useState('');
const [audience, setAudience] = useState('');
const [goal, setGoal] = useState('educational');
const [language, setLanguage] = useState('ru');
// Шаг 2 — стиль
const [tone, setTone] = useState('friendly');
const [formality, setFormality] = useState('informal');
const [humor, setHumor] = useState('moderate');
const [postLength, setPostLength] = useState('medium');
const [emojiLevel, setEmojiLevel] = useState('moderate');
const [hashtagsMode, setHashtagsMode] = useState('end');
// Шаг 3 — примеры и табу
const [examplePosts, setExamplePosts] = useState(['']);
const [bannedWords, setBannedWords] = useState('');
const [bannedTopics, setBannedTopics] = useState('');
async function submit() {
if (!name) { setError('Название канала обязательно'); setStep(1); return; }
setBusy(true);
setError('');
const data = {
name, niche, audience, goal, language, region: 'ru',
style: {
tone, formality, humor,
post_length: postLength,
emoji_level: emojiLevel,
hashtags_mode: hashtagsMode,
example_posts: examplePosts.map(s => s.trim()).filter(Boolean),
banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean),
banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean),
},
};
const res = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json = await res.json();
setBusy(false);
if (!res.ok) { setError(json.error || 'Ошибка'); return; }
router.push(`/channels/${json.id}`);
}
return (
<main className="max-w-3xl 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-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-accent" />
<h1 className="text-2xl font-bold">Новый канал</h1>
</div>
<p className="text-sm text-gray-500 mb-6">
Чем точнее опишешь канал, тем лучше ИИ будет писать в его стиле
</p>
{/* Stepper */}
<div className="flex items-center gap-2 mb-8">
{[1, 2, 3].map(s => (
<div key={s} className="flex-1">
<div className={`h-1 rounded-full transition-colors ${s <= step ? 'bg-accent' : 'bg-border'}`} />
<div className={`text-xs mt-1.5 ${s === step ? 'text-accent font-medium' : 'text-gray-500'}`}>
{s}. {s === 1 ? 'О канале' : s === 2 ? 'Стиль' : 'Примеры и табу'}
</div>
</div>
))}
</div>
{error && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2 mb-4">
{error}
</div>
)}
{step === 1 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Название канала <span className="text-red-400">*</span></label>
<input className="input" value={name} onChange={e => setName(e.target.value)} placeholder="Например: ZeroPost AI" />
</div>
<div>
<label className="label">Ниша / тематика</label>
<textarea
className="input min-h-[80px]"
value={niche}
onChange={e => setNiche(e.target.value)}
placeholder="Не общая категория, а конкретно: «Практические ИИ-инструменты для маркетологов в СНГ»"
/>
<div className="hint">Чем уже ниша, тем сильнее работают посты</div>
</div>
<div>
<label className="label">Целевая аудитория</label>
<textarea
className="input min-h-[80px]"
value={audience}
onChange={e => setAudience(e.target.value)}
placeholder="Кто читает: возраст, профессия, уровень, что им важно"
/>
</div>
<div>
<label className="label">Цель канала</label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{GOALS.map(g => (
<button
key={g.v}
type="button"
onClick={() => setGoal(g.v)}
className={`p-2.5 rounded-lg border text-left transition-colors ${
goal === g.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{g.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{g.desc}</div>
</button>
))}
</div>
</div>
<div>
<label className="label">Язык постов</label>
<select className="input" value={language} onChange={e => setLanguage(e.target.value)}>
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<div className="flex justify-end pt-2">
<button onClick={() => setStep(2)} className="btn-primary">Дальше</button>
</div>
</div>
)}
{step === 2 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Тон</label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{TONES.map(t => (
<button
key={t.v}
type="button"
onClick={() => setTone(t.v)}
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
tone === t.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
{t.label}
</button>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">Обращение к читателю</label>
<div className="grid grid-cols-2 gap-2">
{[{ v: 'informal', l: 'На «ты»' }, { v: 'formal', l: 'На «вы»' }].map(o => (
<button
key={o.v}
type="button"
onClick={() => setFormality(o.v)}
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
formality === o.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>{o.l}</button>
))}
</div>
</div>
<div>
<label className="label">Юмор</label>
<select className="input" value={humor} onChange={e => setHumor(e.target.value)}>
{HUMOR.map(o => <option key={o.v} value={o.v}>{o.label}</option>)}
</select>
</div>
</div>
<div>
<label className="label">Длина постов</label>
<div className="grid grid-cols-3 gap-2">
{LENGTHS.map(l => (
<button
key={l.v}
type="button"
onClick={() => setPostLength(l.v)}
className={`p-2.5 rounded-lg border text-left transition-colors ${
postLength === l.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{l.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{l.desc}</div>
</button>
))}
</div>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">Эмодзи</label>
<select className="input" value={emojiLevel} onChange={e => setEmojiLevel(e.target.value)}>
{EMOJI.map(o => <option key={o.v} value={o.v}>{o.label}</option>)}
</select>
</div>
<div>
<label className="label">Хэштеги</label>
<select className="input" value={hashtagsMode} onChange={e => setHashtagsMode(e.target.value)}>
<option value="none">Не использовать</option>
<option value="end">В конце поста</option>
<option value="inline">Внутри текста</option>
</select>
</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(1)} className="btn-ghost">Назад</button>
<button onClick={() => setStep(3)} className="btn-primary">Дальше</button>
</div>
</div>
)}
{step === 3 && (
<div className="card p-6 space-y-5">
<div>
<label className="label">Примеры «эталонных» постов</label>
<div className="hint mb-3">
Это самое важное! Скопируй 2-3 поста, которые звучат «как надо» из своего канала или у конкурентов. ИИ скопирует их ритм и стиль.
</div>
{examplePosts.map((ex, i) => (
<div key={i} className="relative mb-3">
<textarea
className="input min-h-[120px] pr-10"
value={ex}
onChange={e => {
const arr = [...examplePosts];
arr[i] = e.target.value;
setExamplePosts(arr);
}}
placeholder={`Пример поста №${i + 1}`}
/>
{examplePosts.length > 1 && (
<button
type="button"
onClick={() => setExamplePosts(examplePosts.filter((_, j) => j !== i))}
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-red-400"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
{examplePosts.length < 5 && (
<button
type="button"
onClick={() => setExamplePosts([...examplePosts, ''])}
className="btn-ghost text-sm"
>
<Plus className="w-4 h-4" /> Добавить пример
</button>
)}
</div>
<div>
<label className="label">Стоп-слова</label>
<input
className="input"
value={bannedWords}
onChange={e => setBannedWords(e.target.value)}
placeholder="революционный, уникальный, в современном мире"
/>
<div className="hint">Через запятую. Эти слова ИИ не будет использовать</div>
</div>
<div>
<label className="label">Запрещённые темы</label>
<input
className="input"
value={bannedTopics}
onChange={e => setBannedTopics(e.target.value)}
placeholder="политика, крипта, реклама конкурентов"
/>
<div className="hint">Через запятую. ИИ не будет затрагивать эти темы</div>
</div>
<div className="flex justify-between pt-2">
<button onClick={() => setStep(2)} className="btn-ghost">Назад</button>
<button onClick={submit} disabled={busy} className="btn-primary">
{busy ? 'Создаём...' : 'Создать канал'}
</button>
</div>
</div>
)}
</main>
);
}