forked from admin/zeropost-tool
5dd975a9cd
- Auth: iron-session, регистрация/логин по email+password - Дашборд со списком каналов - 3-шаговая анкета создания канала (база/стиль/примеры+табу) - Страница канала с генератором постов через polling - Тёмная тема, Tailwind 3.4, accent emerald - Прокси-API к zeropost-engine с x-user-id - Совместимость с Next 16 async cookies/params
345 lines
14 KiB
JavaScript
345 lines
14 KiB
JavaScript
'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>
|
||
);
|
||
}
|