Files
zeropost-tool/components/ChannelEdit.js
T
Ник (Claude) a8df9acbcb refactor: remove HD quality option from ChannelEdit AI-стиль tab
Вместо выбора standard/HD показываем информационный блок:
gpt-5-image-mini, routerai.ru, ~₽2.72/картинка, high quality
Убраны: imageQuality state, image_quality payload, HD модель gpt-5.4-image-2
2026-06-11 15:44:33 +03:00

549 lines
28 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, Plus, X, Sparkles, Plug } 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 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: 'AI-фотореализм, не сток' },
{ 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 },
{ id: 'ai', label: 'AI-стиль', icon: Sparkles },
{ id: 'connect', label: 'Подключение', icon: Plug },
];
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 [goals, setGoals] = useState(
channel.goal ? channel.goal.split(',').map(g => g.trim()).filter(Boolean) : ['educational']
);
const [customGoal, setCustomGoal] = useState('');
const [language, setLanguage] = useState(channel.language || 'ru');
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 [imageStyles, setImageStyles] = useState(
(style.image_style || 'flat-illustration').split(',').map(s => s.trim()).filter(Boolean)
);
const [imagePalette, setImagePalette] = useState(style.image_palette || 'auto');
const [imageCustomColors, setImageCustomColors] = useState(style.image_custom_colors || '');
const [imagePromptInstructions, setImagePromptInstructions] = useState(style.image_prompt_instructions || '');
// Подключение
const [botToken, setBotToken] = useState(channel.bot_token || '');
const [tgChannelId, setTgChannelId] = useState(channel.tg_channel_id || '');
const [tgUsername, setTgUsername] = useState(channel.tg_username || '');
const [vkToken, setVkToken] = useState(channel.vk_access_token || '');
const [tokenVerifying, setTokenVerifying] = useState(false);
const [tokenStatus, setTokenStatus] = useState(null); // null | 'ok' | 'error'
// AI-стиль
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
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, goal: goals.join(','), language,
bot_token: botToken.trim() || null,
tg_channel_id: tgChannelId.trim() || null,
tg_username: tgUsername.trim() || null,
vk_access_token: vkToken.trim() || null,
ai_style_prompt: aiStylePrompt.trim() || null,
image_quality: imageQuality,
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: imageStyles.join(','),
image_palette: imagePalette,
image_custom_colors: imageCustomColors.trim() || null,
image_prompt_instructions: imagePromptInstructions.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 (
<main className="max-w-3xl mx-auto p-4 sm:p-6">
<Link href={`/channels/${channel.id}`} className="btn-ghost mb-4 -ml-2">
<ArrowLeft className="w-4 h-4" /> К каналу
</Link>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<h1 className="text-2xl font-bold">Настройки канала</h1>
<div className="flex items-center gap-2">
{toast && <span className="text-sm text-accent">{toast}</span>}
<button onClick={del} disabled={deleting} className="btn-ghost text-sm text-red-400 hover:bg-red-500/10">
<Trash2 className="w-4 h-4" /> {deleting ? '...' : 'Удалить'}
</button>
<button onClick={save} disabled={saving} className="btn-primary">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Сохраняю...' : 'Сохранить'}
</button>
</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>
)}
{/* Tabs */}
<div className="flex gap-1 border-b border-border mb-5">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => 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'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
{/* TAB: Контент */}
{tab === 'content' && (
<div className="space-y-5">
<div className="card p-5 space-y-4">
<div>
<label className="label">Название</label>
<input className="input" value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="label">Ниша</label>
<textarea className="input min-h-[70px]" value={niche} onChange={e => setNiche(e.target.value)} />
</div>
<div>
<label className="label">Аудитория</label>
<textarea className="input min-h-[70px]" value={audience} onChange={e => setAudience(e.target.value)} />
</div>
<div>
<label className="label">Цель канала <span className="text-gray-500 font-normal">(можно несколько)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 mb-2">
{GOALS.map(g => {
const on = goals.includes(g.v);
return (
<button key={g.v} type="button"
onClick={() => setGoals(on ? goals.filter(x => x !== g.v) : [...goals, g.v])}
className={`p-2.5 rounded-lg border text-left transition-colors ${on ? '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 className="flex gap-2">
<input className="input text-sm flex-1" placeholder="Своя цель — введи и нажми +"
value={customGoal} onChange={e => setCustomGoal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}} />
<button type="button" onClick={() => { const v = customGoal.trim(); if (v && !goals.includes(v)) setGoals([...goals, v]); setCustomGoal(''); }}
disabled={!customGoal.trim()} className="btn-primary px-3 disabled:opacity-40"><Plus className="w-4 h-4" /></button>
</div>
{goals.filter(g => !GOALS.find(x => x.v === g)).length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{goals.filter(g => !GOALS.find(x => x.v === g)).map(g => (
<span key={g} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/15 border border-accent/40 text-xs">
{g}<button type="button" onClick={() => setGoals(goals.filter(x => x !== g))}><X className="w-3 h-3" /></button>
</span>
))}
</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>
<option value="uk">Українська</option>
<option value="kk">Қазақша</option>
</select>
</div>
</div>
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-sm">Стиль текста</h3>
<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 ${tone === t.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
{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 ${formality === o.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
{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 ${postLength === l.v ? 'border-accent bg-accent/10' : 'border-border bg-surface2'}`}>
<div className="text-sm font-medium">{l.label}</div>
<div className="text-xs text-gray-500">{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>
<label className="label">Стоп-слова</label>
<input className="input" value={bannedWords} onChange={e => setBannedWords(e.target.value)}
placeholder="революционный, уникальный" />
</div>
<div>
<label className="label">Запрещённые темы</label>
<input className="input" value={bannedTopics} onChange={e => setBannedTopics(e.target.value)}
placeholder="политика, крипта" />
</div>
</div>
</div>
)}
{/* TAB: Картинки */}
{tab === 'images' && (
<div className="space-y-5">
<div className="card p-5">
<div className="flex items-center gap-3 mb-2">
<input
type="checkbox"
id="imageEnabled"
checked={imageEnabled}
onChange={e => setImageEnabled(e.target.checked)}
className="w-4 h-4 accent-accent"
/>
<label htmlFor="imageEnabled" className="font-semibold">Генерировать картинки к постам</label>
</div>
<p className="text-xs text-gray-500 ml-7">
Если включено в редакторе поста появится кнопка генерации картинки. Можешь генерировать вручную для каждого поста.
</p>
</div>
{imageEnabled && (
<>
<div className="card p-5">
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-accent" />
Стиль изображений
<span className="text-gray-500 font-normal">(можно несколько система будет чередовать)</span>
</h3>
<p className="text-xs text-gray-500 mb-3">
Все стили это <b>AI-генерация</b>, не стоковые фото.
Если в посте упоминается реальный человек система автоматически ищет его фото в интернете вместо генерации.
</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{IMAGE_STYLES.map(s => {
const on = imageStyles.includes(s.v);
return (
<button
key={s.v} type="button"
onClick={() => setImageStyles(on ? imageStyles.filter(x => x !== s.v) : [...imageStyles, s.v])}
className={`p-3 rounded-lg border text-left transition-colors ${
on ? 'border-accent bg-accent/10' : 'border-border bg-surface2 hover:border-gray-600'
}`}
>
<div className="text-sm font-medium">{s.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.desc}</div>
</button>
);
})}
</div>
</div>
<div className="card p-5">
<h3 className="font-semibold text-sm mb-3 flex items-center gap-2">
<Palette className="w-4 h-4 text-accent" />
Цветовая палитра
</h3>
<div className="grid grid-cols-3 sm:grid-cols-7 gap-2 mb-4">
{IMAGE_PALETTES.map(p => (
<button
key={p.v} type="button" onClick={() => 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}
</button>
))}
</div>
<div>
<label className="label">Или брендовые цвета (опц.)</label>
<input
className="input font-mono text-sm"
value={imageCustomColors}
onChange={e => setImageCustomColors(e.target.value)}
placeholder="#10b981, #1e293b, #f59e0b"
/>
<div className="hint">Через запятую. Если заполнено приоритет над палитрой</div>
</div>
</div>
{/* Инструкции для AI */}
<div className="card p-5">
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-accent" />
Инструкции для AI
</h3>
<p className="text-xs text-gray-500 mb-3">
Опиши, какими должны быть картинки. Например: <em>«тёмный фон, минималистичные 3D-объекты, технологичная эстетика, без людей»</em>.
Применяется ко всем постам и обложкам статей этого канала.
</p>
<textarea
className="input min-h-[90px] text-sm"
value={imagePromptInstructions}
onChange={e => setImagePromptInstructions(e.target.value)}
placeholder="Примеры:&#10;— технологичный объект на тёмном градиентном фоне, как Stripe/Vercel blog&#10;— реалистичное фото молочного производства, без людей, фокус на деталях&#10;— мягкая пастельная акварель, природа и животные, тёплый тон"
maxLength={500}
/>
<div className="text-xs text-gray-500 text-right mt-1">{imagePromptInstructions.length}/500</div>
</div>
{/* Preview подсказка */}
<div className="card p-4 bg-accent/5 border-accent/20 text-sm text-gray-300">
<div className="font-medium text-accent mb-1">Как это работает</div>
<p className="text-xs">
AI прочитает контент поста и сгенерирует к нему обложку в выбранном стиле.
Генерация занимает ~30 секунд. Если что-то не нравится можно перегенерировать.
</p>
</div>
</>
)}
</div>
)}
{/* TAB: AI-стиль */}
{tab === 'ai' && (
<div className="space-y-5">
{/* Промт для генерации статей */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-accent" />
<h3 className="font-semibold text-sm">Стиль генерации статей</h3>
</div>
<p className="text-xs text-gray-400">
Дополнительные инструкции для AI при автоматической генерации статей в этом канале.
Применяется ко всем статьям канала. При ручной генерации можно переопределить.
</p>
<textarea
rows={5}
placeholder={`Например:\n• Пиши в стиле новостной заметки, без воды\n• Аудитория: молочные фермеры Сибири\n• Всегда заканчивай призывом к действию\n• Включай конкретные цифры и факты`}
value={aiStylePrompt}
onChange={e => setAiStylePrompt(e.target.value)}
className="input w-full text-sm resize-none"
/>
<p className="text-xs text-gray-500">
{aiStylePrompt.length}/1000 символов
</p>
</div>
{/* Модель генерации картинок — только gpt-5-image-mini */}
<div className="card p-5">
<div className="flex items-center gap-2 mb-3">
<ImageIcon className="w-4 h-4 text-accent" />
<h3 className="font-semibold text-sm">Генерация картинок</h3>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center text-base">🖼</div>
<div className="flex-1">
<div className="text-sm font-medium">gpt-5-image-mini</div>
<div className="text-xs text-gray-400 mt-0.5">routerai.ru ~2.72/картинка high quality</div>
</div>
<span className="text-xs px-2 py-0.5 rounded bg-green-500/20 text-green-400">Активна</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Единственная модель для генерации изображений. Параметр качества фиксирован провайдером.
</p>
</div>
</div>
)}
{/* TAB: Подключение */}
{tab === 'connect' && (
<div className="space-y-5">
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg"></span>
<h3 className="font-semibold">Telegram</h3>
</div>
<div className="bg-surface2/50 rounded-lg p-3 text-xs text-gray-400 space-y-1 border border-border">
<div className="font-medium text-gray-300 mb-2">Как подключить:</div>
<div>1. Создай бота через <span className="text-accent">@BotFather</span> <code>/newbot</code> скопируй токен</div>
<div>2. Добавь бота <b>администратором</b> в свой канал (права: публикация сообщений)</div>
<div>3. Узнай ID канала: перешли сообщение из канала боту <span className="text-accent">@idbot</span> вернёт ID вида <code>-100xxxxxxxxxx</code></div>
</div>
<div>
<label className="label">Bot Token</label>
<input className="input font-mono text-sm" placeholder="7123456789:AAHxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
value={botToken} onChange={e => { setBotToken(e.target.value); setTokenStatus(null); }} type="password" />
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="label">ID канала</label>
<input className="input font-mono text-sm" placeholder="-1001234567890"
value={tgChannelId} onChange={e => setTgChannelId(e.target.value)} />
<div className="hint">Начинается с -100</div>
</div>
<div>
<label className="label">Username канала</label>
<input className="input font-mono text-sm" placeholder="@mychannel"
value={tgUsername} onChange={e => setTgUsername(e.target.value)} />
<div className="hint">Необязательно если заполнен ID</div>
</div>
</div>
</div>
<div className="card p-5 space-y-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">🅱</span>
<h3 className="font-semibold">ВКонтакте</h3>
</div>
<div>
<label className="label">Access Token группы</label>
<input className="input font-mono text-sm" placeholder="vk1.a.xxx..."
value={vkToken} onChange={e => setVkToken(e.target.value)} type="password" />
<div className="hint">Управление API Ключи доступа Создать ключ (права: wall, photos)</div>
</div>
</div>
</div>
)}
</main>
);
}