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} }
+
+ {deleting ? '...' : 'Удалить'}
+
+
+ {saving ? : }
+ {saving ? 'Сохраняю...' : 'Сохранить'}
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+ {/* Tabs */}
+
+ {TABS.map(({ id, label, icon: Icon }) => (
+ setTab(id)}
+ className={`px-4 py-2 text-sm font-medium border-b-2 inline-flex items-center gap-1.5 transition-colors ${
+ tab === id ? 'border-accent text-accent' : 'border-transparent text-gray-500 hover:text-gray-300'
+ }`}
+ >
+
+ {label}
+
+ ))}
+
+
+ {/* TAB: Контент */}
+ {tab === 'content' && (
+
+
+
+ Название
+ setName(e.target.value)} />
+
+
+ Ниша
+
+
+ Аудитория
+
+
+
+
+
Стиль текста
+
+
Тон
+
+ {TONES.map(t => (
+ setTone(t.v)}
+ className={`px-3 py-2 rounded-lg border text-sm ${tone === t.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
+ {t.label}
+
+ ))}
+
+
+
+
+
Обращение
+
+ {[{ v: 'informal', l: 'На «ты»' }, { v: 'formal', l: 'На «вы»' }].map(o => (
+ setFormality(o.v)}
+ className={`px-3 py-2 rounded-lg border text-sm ${formality === o.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
+ {o.l}
+
+ ))}
+
+
+
+ Юмор
+ setHumor(e.target.value)}>
+ {HUMOR.map(o => {o.label} )}
+
+
+
+
+
Длина постов
+
+ {LENGTHS.map(l => (
+
setPostLength(l.v)}
+ className={`p-2.5 rounded-lg border text-left ${postLength === l.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
+ {l.label}
+ {l.desc}
+
+ ))}
+
+
+
+
+ Эмодзи
+ setEmojiLevel(e.target.value)}>
+ {EMOJI.map(o => {o.label} )}
+
+
+
+ Хэштеги
+ setHashtagsMode(e.target.value)}>
+ Не использовать
+ В конце поста
+ Внутри текста
+
+
+
+
+ Стоп-слова
+ setBannedWords(e.target.value)}
+ placeholder="революционный, уникальный" />
+
+
+ Запрещённые темы
+ setBannedTopics(e.target.value)}
+ placeholder="политика, крипта" />
+
+
+
+ )}
+
+ {/* TAB: Картинки */}
+ {tab === 'images' && (
+
+
+
+ setImageEnabled(e.target.checked)}
+ className="w-4 h-4 accent-accent"
+ />
+ Генерировать картинки к постам
+
+
+ Если включено — в редакторе поста появится кнопка генерации картинки. Можешь генерировать вручную для каждого поста.
+
+
+
+ {imageEnabled && (
+ <>
+
+
+
+ Стиль изображений
+
+
+ {IMAGE_STYLES.map(s => (
+
setImageStyle(s.v)}
+ className={`p-3 rounded-lg border text-left transition-colors ${
+ imageStyle === s.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
+ }`}
+ >
+ {s.label}
+ {s.desc}
+
+ ))}
+
+
+
+
+
+
+ Цветовая палитра
+
+
+ {IMAGE_PALETTES.map(p => (
+ setImagePalette(p.v)}
+ className={`px-3 py-2 rounded-lg border text-sm ${
+ imagePalette === p.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'
+ }`}
+ >
+ {p.label}
+
+ ))}
+
+
+
Или брендовые цвета (опц.)
+
setImageCustomColors(e.target.value)}
+ placeholder="#10b981, #1e293b, #f59e0b"
+ />
+
Через запятую. Если заполнено — приоритет над палитрой
+
+
+
+ {/* Preview подсказка */}
+
+
Как это работает
+
+ AI прочитает контент поста и сгенерирует к нему обложку в выбранном стиле.
+ Генерация занимает ~30 секунд. Если что-то не нравится — можно перегенерировать.
+
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/components/ChannelView.js b/components/ChannelView.js
index 302eb41..f259ea9 100644
--- a/components/ChannelView.js
+++ b/components/ChannelView.js
@@ -1,13 +1,27 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
-import { ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings } from 'lucide-react';
+import {
+ ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
+ Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
+ MessageSquare, Pencil, X, ChevronDown
+} from 'lucide-react';
const GOAL_LABELS = {
educational: 'Обучение', news: 'Новости',
entertainment: 'Развлечение', expert: 'Экспертный', sales: 'Продажи',
};
+const TRANSFORMS = [
+ { action: 'shorter', label: 'Короче', icon: Scissors, desc: 'Сократить в 2 раза' },
+ { action: 'longer', label: 'Длиннее', icon: Maximize2, desc: 'Расширить с примерами' },
+ { action: 'improve', label: 'Улучшить', icon: Sparkles, desc: 'Убрать AI-штампы' },
+ { action: 'bolder', label: 'Дерзче', icon: Zap, desc: 'Острее формулировки' },
+ { action: 'softer', label: 'Мягче', icon: Heart, desc: 'Доброжелательней' },
+ { action: 'addCta', label: 'Призыв', icon: MessageSquare, desc: 'Добавить CTA' },
+ { action: 'forVk', label: 'Для ВК', icon: RefreshCw, desc: 'Адаптировать под ВКонтакте' },
+];
+
export default function ChannelView({ channel }) {
const [topic, setTopic] = useState('');
const [generating, setGenerating] = useState(false);
@@ -16,42 +30,79 @@ export default function ChannelView({ channel }) {
const [copied, setCopied] = useState(false);
const [tokens, setTokens] = useState(null);
- async function generate() {
- if (!topic.trim()) return;
+ // Варианты постов (история)
+ const [variants, setVariants] = useState([]);
+ const [editing, setEditing] = useState(false);
+
+ // Картинка
+ const [image, setImage] = useState(null);
+ const [genImage, setGenImage] = useState(false);
+
+ // Трансформации
+ const [transforming, setTransforming] = useState(false);
+
+ // Идеи тем
+ const [showIdeas, setShowIdeas] = useState(false);
+ const [ideas, setIdeas] = useState([]);
+ const [loadingIdeas, setLoadingIdeas] = useState(false);
+
+ async function fetchIdeas() {
+ setLoadingIdeas(true);
+ setError('');
+ try {
+ const res = await fetch('/api/generate/topics-ideas', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ channelId: channel.id, count: 7 }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Ошибка');
+ setIdeas(data.topics || []);
+ setShowIdeas(true);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoadingIdeas(false);
+ }
+ }
+
+ async function generate(asVariant = false) {
+ if (!topic.trim() && !asVariant) return;
+ if (asVariant && !post) return;
setGenerating(true);
setError('');
- setPost(null);
- setTokens(null);
+
+ const useTopic = asVariant ? `${topic} (вариант ${variants.length + 2})` : topic.trim();
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,
+ type: 'post', channelId: channel.id, topic: useTopic, 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 (j.status === 'done' || j.status === 'failed') { final = j; break; }
}
if (!final) throw new Error('Таймаут — попробуй ещё раз');
if (final.status === 'failed') throw new Error(final.error || 'Генерация упала');
+
+ // Сохраняем предыдущий вариант в variants
+ if (asVariant && post) {
+ setVariants(v => [...v, { content: post, tokens, image }]);
+ }
+
setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out });
+ setImage(null); // сбрасываем картинку при новом посте
} catch (err) {
setError(err.message);
} finally {
@@ -59,6 +110,61 @@ export default function ChannelView({ channel }) {
}
}
+ async function transform(action) {
+ if (!post || transforming) return;
+ setTransforming(true);
+ setError('');
+ try {
+ const res = await fetch('/api/generate/transform', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ channelId: channel.id, originalPost: post, action }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Ошибка');
+ // Сохраняем текущий в варианты
+ setVariants(v => [...v, { content: post, tokens, image }]);
+ setPost(data.content);
+ setImage(null);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setTransforming(false);
+ }
+ }
+
+ async function generateImage() {
+ if (!post || genImage) return;
+ setGenImage(true);
+ setError('');
+ try {
+ const res = await fetch('/api/generate/post-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ channelId: channel.id, post }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Ошибка генерации картинки');
+ setImage(data.url);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setGenImage(false);
+ }
+ }
+
+ function restoreVariant(idx) {
+ const v = variants[idx];
+ setVariants(arr => {
+ const next = arr.filter((_, i) => i !== idx);
+ next.push({ content: post, tokens, image });
+ return next;
+ });
+ setPost(v.content);
+ setTokens(v.tokens);
+ setImage(v.image);
+ }
+
async function copy() {
await navigator.clipboard.writeText(post);
setCopied(true);
@@ -90,10 +196,44 @@ export default function ChannelView({ channel }) {
{/* Generator */}
-
-
- Сгенерировать пост
-
+
+
+
+ Сгенерировать пост
+
+
+ {loadingIdeas ? : }
+ Идеи тем
+
+
+
+ {/* Список идей */}
+ {showIdeas && ideas.length > 0 && (
+
+
+ Идеи для постов
+ setShowIdeas(false)} className="text-xs text-gray-500 hover:text-gray-300">
+
+
+
+
+ {ideas.map((idea, i) => (
+ { setTopic(idea); setShowIdeas(false); }}
+ className="block w-full text-left text-sm px-2.5 py-1.5 rounded hover:bg-accent/10 text-gray-300 hover:text-white transition-colors"
+ >
+ → {idea}
+
+ ))}
+
+
+ )}
+
-
+ generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? (
<> Генерирую...>
) : (
@@ -122,15 +262,30 @@ export default function ChannelView({ channel }) {
{/* Result */}
{post && (
-
-
-
Результат
-
+
+
+
+ Результат
+ {(transforming || genImage) && }
+
+
{tokens && (
-
- {tokens.in}/{tokens.out} токенов
-
+
{tokens.in}/{tokens.out} ток.
)}
+
setEditing(v => !v)}
+ className="btn-ghost text-sm py-1"
+ >
+ {editing ? 'Готово' : 'Править'}
+
+
generate(true)}
+ disabled={generating}
+ className="btn-ghost text-sm py-1"
+ title="Сгенерировать ещё один вариант"
+ >
+ Ещё вариант
+
{copied ? (
<> Скопировано>
@@ -140,8 +295,96 @@ export default function ChannelView({ channel }) {
-
- {post}
+
+ {/* Сам пост — редактируемый или нет */}
+ {editing ? (
+
+ )}
+
+ {/* История вариантов */}
+ {variants.length > 0 && (
+
+
+
Предыдущие варианты ({variants.length})
+ setVariants([])} className="text-xs text-gray-500 hover:text-red-400">
+ Очистить
+
+
+
+ {variants.map((v, i) => (
+
+
+ {v.content}
+
+
restoreVariant(i)}
+ className="text-xs text-accent hover:underline shrink-0"
+ title="Сделать активным"
+ >
+ ← вернуть
+
+
+ ))}
)}
diff --git a/lib/engine.js b/lib/engine.js
index ba6d301..d7706d8 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -37,4 +37,8 @@ export const engine = {
// Generation
generate: (userId, data) => call('/api/generate/', { userId, method: 'POST', body: data }),
getJob: (userId, id) => call(`/api/generate/${id}`, { userId }),
+ transformPost: (userId, data) => call('/api/generate/transform', { userId, method: 'POST', body: data }),
+ generatePostImage: (userId, data) => call('/api/generate/post-image', { userId, method: 'POST', body: data }),
+ topicsIdeas: (userId, data) => call('/api/generate/topics-ideas', { userId, method: 'POST', body: data }),
+ getImageStyles: () => call('/api/generate/image-styles'),
};