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,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { q } from '@/lib/db';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const { email, password, mode = 'login' } = await req.json();
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json({ error: 'email и password обязательны' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'register') {
|
||||||
|
const exists = await q(`SELECT id FROM users WHERE email=$1`, [email]);
|
||||||
|
if (exists.rows.length) {
|
||||||
|
return NextResponse.json({ error: 'Пользователь уже существует' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
const { rows } = await q(
|
||||||
|
`INSERT INTO users (email,password) VALUES ($1,$2) RETURNING id,email,name`,
|
||||||
|
[email, hash]
|
||||||
|
);
|
||||||
|
const user = rows[0];
|
||||||
|
const s = await getSession();
|
||||||
|
s.userId = user.id;
|
||||||
|
s.email = user.email;
|
||||||
|
await s.save();
|
||||||
|
return NextResponse.json({ ok: true, user });
|
||||||
|
}
|
||||||
|
|
||||||
|
// login
|
||||||
|
const { rows } = await q(`SELECT id,email,password,name FROM users WHERE email=$1`, [email]);
|
||||||
|
if (!rows.length) {
|
||||||
|
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const user = rows[0];
|
||||||
|
const ok = await bcrypt.compare(password, user.password);
|
||||||
|
if (!ok) {
|
||||||
|
return NextResponse.json({ error: 'Неверный email или пароль' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const s = await getSession();
|
||||||
|
s.userId = user.id;
|
||||||
|
s.email = user.email;
|
||||||
|
s.name = user.name;
|
||||||
|
await s.save();
|
||||||
|
return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const s = await getSession();
|
||||||
|
s.destroy();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET(_, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const channel = await engine.getChannel(user.id, id);
|
||||||
|
return NextResponse.json(channel);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const channel = await engine.updateChannel(user.id, id, body);
|
||||||
|
return NextResponse.json(channel);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
await engine.deleteChannel(user.id, id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const channels = await engine.listChannels(user.id);
|
||||||
|
return NextResponse.json(channels);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const channel = await engine.createChannel(user.id, body);
|
||||||
|
return NextResponse.json(channel);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET(_, { params }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const job = await engine.getJob(user.id, id);
|
||||||
|
return NextResponse.json(job);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const job = await engine.generate(user.id, body);
|
||||||
|
return NextResponse.json(job);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html { @apply bg-bg text-gray-100; }
|
||||||
|
body { @apply font-sans antialiased; }
|
||||||
|
::selection { @apply bg-accent/30; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn { @apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed; }
|
||||||
|
.btn-primary { @apply btn bg-accent text-black hover:bg-accent2; }
|
||||||
|
.btn-ghost { @apply btn text-gray-300 hover:bg-surface2 hover:text-white; }
|
||||||
|
.btn-danger { @apply btn bg-red-600/10 text-red-400 hover:bg-red-600/20; }
|
||||||
|
.card { @apply bg-surface border border-border rounded-xl; }
|
||||||
|
.input { @apply w-full bg-surface2 border border-border rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-accent transition-colors; }
|
||||||
|
.label { @apply block text-sm font-medium text-gray-300 mb-1.5; }
|
||||||
|
.hint { @apply text-xs text-gray-500 mt-1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
@apply bg-bg text-gray-100;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply font-sans antialiased;
|
||||||
|
}
|
||||||
|
::selection {
|
||||||
|
@apply bg-accent/30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-accent text-black hover:bg-accent2;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn text-gray-300 hover:bg-surface2 hover:text-white;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn bg-red-600/10 text-red-400 hover:bg-red-600/20;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply bg-surface border border-border rounded-xl;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full bg-surface2 border border-border rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-accent transition-colors;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-gray-300 mb-1.5;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
@apply text-xs text-gray-500 mt-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'ZeroPost — ИИ-генерация постов для Telegram',
|
||||||
|
description: 'Инструмент для ведения Telegram-каналов с помощью ИИ',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mode, setMode] = useState('login');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password, mode }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || 'Ошибка');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="flex items-center gap-2 mb-8 justify-center">
|
||||||
|
<Sparkles className="w-7 h-7 text-accent" />
|
||||||
|
<span className="text-2xl font-bold">ZeroPost</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-8">
|
||||||
|
<h1 className="text-xl font-semibold mb-1">
|
||||||
|
{mode === 'login' ? 'Вход' : 'Регистрация'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
ИИ-ассистент для Telegram-каналов
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button disabled={busy} className="btn-primary w-full">
|
||||||
|
{busy ? 'Подождите...' : (mode === 'login' ? 'Войти' : 'Зарегистрироваться')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode(mode === 'login' ? 'register' : 'login'); setError(''); }}
|
||||||
|
className="w-full text-center text-sm text-gray-500 hover:text-gray-300 mt-6"
|
||||||
|
>
|
||||||
|
{mode === 'login' ? 'Нет аккаунта? Зарегистрироваться' : 'Уже есть аккаунт? Войти'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
import Header from '@/components/Header';
|
||||||
|
import { Plus, MessageSquare, Users, Target } from 'lucide-react';
|
||||||
|
|
||||||
|
const GOAL_LABELS = {
|
||||||
|
educational: 'Обучение',
|
||||||
|
news: 'Новости',
|
||||||
|
entertainment: 'Развлечение',
|
||||||
|
expert: 'Экспертный',
|
||||||
|
sales: 'Продажи',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user) redirect('/login');
|
||||||
|
|
||||||
|
let channels = [];
|
||||||
|
try {
|
||||||
|
channels = await engine.listChannels(user.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('listChannels failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header user={user} />
|
||||||
|
<main className="max-w-6xl mx-auto p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Мои каналы</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Управляй контентом и публикациями
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/channels/new" className="btn-primary">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Добавить канал
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<div className="card p-12 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-surface2 mb-4">
|
||||||
|
<MessageSquare className="w-7 h-7 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Пока пусто</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Создай первый канал, чтобы начать генерировать посты
|
||||||
|
</p>
|
||||||
|
<Link href="/channels/new" className="btn-primary">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Создать канал
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{channels.map(ch => (
|
||||||
|
<Link
|
||||||
|
key={ch.id}
|
||||||
|
href={`/channels/${ch.id}`}
|
||||||
|
className="card p-5 hover:border-accent/40 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="font-semibold group-hover:text-accent transition-colors">
|
||||||
|
{ch.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
|
||||||
|
{GOAL_LABELS[ch.goal] || ch.goal}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ch.niche && (
|
||||||
|
<p className="text-xs text-gray-500 line-clamp-2 mb-3">
|
||||||
|
{ch.niche}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
{ch.audience && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
Есть ЦА
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ch.style?.example_posts?.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Target className="w-3 h-3 text-accent" />
|
||||||
|
{ch.style.example_posts.length} пример{ch.style.example_posts.length === 1 ? '' : 'а'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Прямой клиент к БД zeropost (для авторизации — engine не даёт login роута)
|
||||||
|
*/
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
export function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
pool = new Pool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || 5432),
|
||||||
|
database: process.env.DB_NAME || 'zeropost',
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASS || 'postgres',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const q = (text, params) => getPool().query(text, params);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Engine client — единая точка вызовов к zeropost-engine
|
||||||
|
*/
|
||||||
|
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3040';
|
||||||
|
const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026';
|
||||||
|
|
||||||
|
async function call(path, options = {}) {
|
||||||
|
const { userId, body, method = 'GET' } = options;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-internal-secret': ENGINE_SECRET,
|
||||||
|
};
|
||||||
|
if (userId) headers['x-user-id'] = String(userId);
|
||||||
|
|
||||||
|
const url = `${ENGINE_URL}${path}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || `Engine ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const engine = {
|
||||||
|
// Channels
|
||||||
|
listChannels: (userId) => call('/api/channels/', { userId }),
|
||||||
|
getChannel: (userId, id) => call(`/api/channels/${id}`, { userId }),
|
||||||
|
createChannel: (userId, data) => call('/api/channels/', { userId, method: 'POST', body: data }),
|
||||||
|
updateChannel: (userId, id, data) => call(`/api/channels/${id}`, { userId, method: 'PATCH', body: data }),
|
||||||
|
deleteChannel: (userId, id) => call(`/api/channels/${id}`, { userId, method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Generation
|
||||||
|
generate: (userId, data) => call('/api/generate/', { userId, method: 'POST', body: data }),
|
||||||
|
getJob: (userId, id) => call(`/api/generate/${id}`, { userId }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { getIronSession } from 'iron-session';
|
||||||
|
|
||||||
|
const sessionOptions = {
|
||||||
|
cookieName: 'zeropost_session',
|
||||||
|
password: process.env.SESSION_SECRET || 'this_is_a_dev_secret_change_in_prod_at_least_32_chars',
|
||||||
|
cookieOptions: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
return getIronSession(cookieStore, sessionOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser() {
|
||||||
|
const s = await getSession();
|
||||||
|
if (!s.userId) return null;
|
||||||
|
return { id: s.userId, email: s.email, name: s.name };
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
Generated
+2201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "zeropost-tool",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3041",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3041"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^16.2.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"lucide-react": "0.408.0",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"iron-session": "8.0.3",
|
||||||
|
"pg": "^8.21.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"tailwindcss": "3.4.7",
|
||||||
|
"autoprefixer": "10.4.19",
|
||||||
|
"postcss": "8.4.39"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./app/**/*.{js,jsx,ts,tsx}',
|
||||||
|
'./components/**/*.{js,jsx,ts,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: '#0a0a0a',
|
||||||
|
surface: '#141414',
|
||||||
|
surface2: '#1c1c1c',
|
||||||
|
border: '#2a2a2a',
|
||||||
|
accent: '#10b981',
|
||||||
|
accent2: '#34d399',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user