diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80c6b2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.next/ +.env*.local +.env +*.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d977110 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +prefix= +production=false diff --git a/app/api/auth/login/route.js b/app/api/auth/login/route.js new file mode 100644 index 0000000..fff57fd --- /dev/null +++ b/app/api/auth/login/route.js @@ -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 } }); +} diff --git a/app/api/auth/logout/route.js b/app/api/auth/logout/route.js new file mode 100644 index 0000000..51f92f2 --- /dev/null +++ b/app/api/auth/logout/route.js @@ -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 }); +} diff --git a/app/api/channels/[id]/route.js b/app/api/channels/[id]/route.js new file mode 100644 index 0000000..e815c49 --- /dev/null +++ b/app/api/channels/[id]/route.js @@ -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 }); + } +} diff --git a/app/api/channels/route.js b/app/api/channels/route.js new file mode 100644 index 0000000..be2288d --- /dev/null +++ b/app/api/channels/route.js @@ -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 }); + } +} diff --git a/app/api/generate/[id]/route.js b/app/api/generate/[id]/route.js new file mode 100644 index 0000000..5623ce9 --- /dev/null +++ b/app/api/generate/[id]/route.js @@ -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 }); + } +} diff --git a/app/api/generate/route.js b/app/api/generate/route.js new file mode 100644 index 0000000..04b9a03 --- /dev/null +++ b/app/api/generate/route.js @@ -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 }); + } +} diff --git a/app/channels/[id]/page.js b/app/channels/[id]/page.js new file mode 100644 index 0000000..f1aec63 --- /dev/null +++ b/app/channels/[id]/page.js @@ -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 ( + <> +
+ + + ); +} diff --git a/app/channels/new/page.js b/app/channels/new/page.js new file mode 100644 index 0000000..481fc3f --- /dev/null +++ b/app/channels/new/page.js @@ -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 ( +
+ + Назад + + +
+ +

Новый канал

+
+

+ Чем точнее опишешь канал, тем лучше ИИ будет писать в его стиле +

+ + {/* Stepper */} +
+ {[1, 2, 3].map(s => ( +
+
+
+ {s}. {s === 1 ? 'О канале' : s === 2 ? 'Стиль' : 'Примеры и табу'} +
+
+ ))} +
+ + {error && ( +
+ {error} +
+ )} + + {step === 1 && ( +
+
+ + setName(e.target.value)} placeholder="Например: ZeroPost AI" /> +
+
+ +