From e2b64baf2e2142502c613fb9a81c64867354b61d Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 17:32:39 +0300 Subject: [PATCH] feat: post variants, transforms, image generation, AI ideas, channel edit page with image style settings --- app/api/generate/post-image/route.js | 15 ++ app/api/generate/topics-ideas/route.js | 15 ++ app/api/generate/transform/route.js | 15 ++ app/channels/[id]/edit/page.js | 20 ++ components/ChannelEdit.js | 346 +++++++++++++++++++++++++ components/ChannelView.js | 299 +++++++++++++++++++-- lib/engine.js | 4 + 7 files changed, 686 insertions(+), 28 deletions(-) create mode 100644 app/api/generate/post-image/route.js create mode 100644 app/api/generate/topics-ideas/route.js create mode 100644 app/api/generate/transform/route.js create mode 100644 app/channels/[id]/edit/page.js create mode 100644 components/ChannelEdit.js diff --git a/app/api/generate/post-image/route.js b/app/api/generate/post-image/route.js new file mode 100644 index 0000000..e8d99aa --- /dev/null +++ b/app/api/generate/post-image/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 }); + const body = await req.json(); + try { + const result = await engine.generatePostImage(user.id, body); + return NextResponse.json(result); + } catch (e) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/api/generate/topics-ideas/route.js b/app/api/generate/topics-ideas/route.js new file mode 100644 index 0000000..a8c93f4 --- /dev/null +++ b/app/api/generate/topics-ideas/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 }); + const body = await req.json(); + try { + const result = await engine.topicsIdeas(user.id, body); + return NextResponse.json(result); + } catch (e) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/api/generate/transform/route.js b/app/api/generate/transform/route.js new file mode 100644 index 0000000..a931b98 --- /dev/null +++ b/app/api/generate/transform/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 }); + const body = await req.json(); + try { + const result = await engine.transformPost(user.id, body); + return NextResponse.json(result); + } catch (e) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/channels/[id]/edit/page.js b/app/channels/[id]/edit/page.js new file mode 100644 index 0000000..2ff949b --- /dev/null +++ b/app/channels/[id]/edit/page.js @@ -0,0 +1,20 @@ +import { notFound, redirect } from 'next/navigation'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; +import Header from '@/components/Header'; +import ChannelEdit from '@/components/ChannelEdit'; + +export default async function ChannelEditPage({ 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/components/ChannelEdit.js b/components/ChannelEdit.js new file mode 100644 index 0000000..c1dc73a --- /dev/null +++ b/components/ChannelEdit.js @@ -0,0 +1,346 @@ +'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Save, Trash2, Loader2, Image as ImageIcon, Type, Palette } from 'lucide-react'; + +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 HUMOR = [ + { v: 'none', label: 'Без юмора' }, + { v: 'dry', label: 'Сухой/ирония' }, + { v: 'moderate', label: 'Умеренный' }, + { v: 'playful', label: 'Игривый' }, +]; + +const EMOJI = [ + { v: 'none', label: 'Без эмодзи' }, + { v: 'moderate', label: 'Умеренно' }, + { v: 'active', label: 'Активно' }, +]; + +const IMAGE_STYLES = [ + { v: 'realistic-photo', label: 'Реалистичное фото', desc: 'Стоковая фотография' }, + { v: 'flat-illustration', label: 'Плоская иллюстрация', desc: 'Editorial vector' }, + { v: '3d-render', label: '3D рендер', desc: 'Pixar-like' }, + { v: 'cartoon', label: 'Мультяшный', desc: 'Comic book' }, + { v: 'minimal', label: 'Минимализм', desc: 'Один элемент' }, + { v: 'abstract', label: 'Абстракция', desc: 'Без объектов' }, + { v: 'sketch', label: 'Скетч', desc: 'Карандашный рисунок' }, + { v: 'cyberpunk', label: 'Киберпанк', desc: 'Неон, будущее' }, +]; + +const IMAGE_PALETTES = [ + { v: 'auto', label: 'Авто' }, + { v: 'dark', label: 'Тёмная' }, + { v: 'light', label: 'Светлая' }, + { v: 'warm', label: 'Тёплая' }, + { v: 'cool', label: 'Холодная' }, + { v: 'mono', label: 'Монохром' }, + { v: 'vibrant', label: 'Яркая' }, +]; + +const TABS = [ + { id: 'content', label: 'Контент', icon: Type }, + { id: 'images', label: 'Картинки', icon: ImageIcon }, +]; + +export default function ChannelEdit({ channel }) { + const router = useRouter(); + const style = channel.style || {}; + const [tab, setTab] = useState('content'); + + // Контент + const [name, setName] = useState(channel.name || ''); + const [niche, setNiche] = useState(channel.niche || ''); + const [audience, setAudience] = useState(channel.audience || ''); + const [tone, setTone] = useState(style.tone || 'friendly'); + const [formality, setFormality] = useState(style.formality || 'informal'); + const [humor, setHumor] = useState(style.humor || 'moderate'); + const [postLength, setPostLength] = useState(style.post_length || 'medium'); + const [emojiLevel, setEmojiLevel] = useState(style.emoji_level || 'moderate'); + const [hashtagsMode, setHashtagsMode] = useState(style.hashtags_mode || 'end'); + const [bannedWords, setBannedWords] = useState((style.banned_words || []).join(', ')); + const [bannedTopics, setBannedTopics] = useState((style.banned_topics || []).join(', ')); + + // Картинки + const [imageEnabled, setImageEnabled] = useState(style.image_enabled ?? false); + const [imageStyle, setImageStyle] = useState(style.image_style || 'flat-illustration'); + const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto'); + const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || ''); + + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(''); + const [toast, setToast] = useState(''); + + async function save() { + setSaving(true); + setError(''); + try { + const data = { + name, niche, audience, + style: { + tone, formality, humor, + post_length: postLength, + emoji_level: emojiLevel, + hashtags_mode: hashtagsMode, + banned_words: bannedWords.split(',').map(s => s.trim()).filter(Boolean), + banned_topics: bannedTopics.split(',').map(s => s.trim()).filter(Boolean), + image_enabled: imageEnabled, + image_style: imageStyle, + image_palette: imagePalette, + image_custom_colors: imageCustomColors.trim() || null, + }, + }; + const res = await fetch(`/api/channels/${channel.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(await res.text()); + setToast('Сохранено'); + setTimeout(() => setToast(''), 2500); + router.refresh(); + } catch (e) { setError(e.message); } + finally { setSaving(false); } + } + + async function del() { + if (!confirm(`Удалить канал «${channel.name}»? Это действие необратимо.`)) return; + setDeleting(true); + try { + await fetch(`/api/channels/${channel.id}`, { method: 'DELETE' }); + router.push('/'); + } catch (e) { setError(e.message); setDeleting(false); } + } + + return ( +
+ + К каналу + + +
+

Настройки канала

+
+ {toast && {toast}} + + +
+
+ + {error && ( +
{error}
+ )} + + {/* Tabs */} +
+ {TABS.map(({ id, label, icon: Icon }) => ( + + ))} +
+ + {/* TAB: Контент */} + {tab === 'content' && ( +
+
+
+ + setName(e.target.value)} /> +
+
+ +