Files
postcast-tool/components/ChannelEdit.js
T

347 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } 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 (
<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>
<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-3 flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-accent" />
Стиль изображений
</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{IMAGE_STYLES.map(s => (
<button
key={s.v} type="button" onClick={() => 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'
}`}
>
<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>
{/* 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>
)}
</main>
);
}