Files
postcast-tool/components/ChannelEdit.js
T

496 lines
25 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: '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'
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,
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: Подключение */}
{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>
);
}