Files
postcast-tool/components/ChannelView.js
T

585 lines
24 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, useEffect } from 'react';
import Link from 'next/link';
import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History
} 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);
const [post, setPost] = useState(null);
const [error, setError] = useState('');
const [copied, setCopied] = useState(false);
const [tokens, setTokens] = useState(null);
// Варианты постов (история)
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);
}
}
// Сохранение и публикация
const [savedPostId, setSavedPostId] = useState(null);
const [publishing, setPublishing] = useState(false);
const [showScheduler, setShowScheduler] = useState(false);
const [scheduleAt, setScheduleAt] = useState('');
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
// Подгрузка истории при монтировании
useEffect(() => { loadHistory(); }, []);
async function loadHistory() {
setLoadingHistory(true);
try {
const res = await fetch(`/api/user-posts?channel_id=${channel.id}&limit=20`);
const data = await res.json();
if (Array.isArray(data)) setHistory(data);
} catch {} finally { setLoadingHistory(false); }
}
async function savePost(status = 'draft', scheduledAt = null) {
if (!post) return;
setPublishing(true);
setError('');
try {
let id = savedPostId;
if (!id) {
// Создаём
const res = await fetch('/api/user-posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: channel.id, content: post, image_url: image,
topic: topic.trim(), status, scheduled_at: scheduledAt,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
id = data.id;
setSavedPostId(id);
} else {
// Обновляем
const res = await fetch(`/api/user-posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }),
});
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
}
await loadHistory();
return id;
} catch (err) {
setError(err.message);
return null;
} finally {
setPublishing(false);
}
}
async function publishNow() {
const id = await savePost('draft');
if (!id) return;
setPublishing(true);
try {
const res = await fetch(`/api/user-posts/${id}/publish`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
await loadHistory();
setPost(null);
setSavedPostId(null);
setImage(null);
setTopic('');
} catch (err) { setError(err.message); }
finally { setPublishing(false); }
}
async function schedule() {
if (!scheduleAt) return setError('Укажите время');
const id = await savePost('scheduled', new Date(scheduleAt).toISOString());
if (!id) return;
setShowScheduler(false);
setScheduleAt('');
setPost(null);
setSavedPostId(null);
setImage(null);
setTopic('');
}
async function generate(asVariant = false) {
if (!topic.trim() && !asVariant) return;
if (asVariant && !post) return;
setGenerating(true);
setError('');
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: useTopic, useCritique: true,
}),
});
const job = await createRes.json();
if (!createRes.ok) throw new Error(job.error || 'Ошибка');
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 (!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 {
setGenerating(false);
}
}
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);
setTimeout(() => setCopied(false), 2000);
}
return (
<main className="max-w-4xl mx-auto p-4 sm:p-6">
<Link href="/" className="btn-ghost mb-4 -ml-2">
<ArrowLeft className="w-4 h-4" /> К списку каналов
</Link>
<div className="flex items-start justify-between mb-6 flex-wrap gap-3">
<div>
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-5 h-5 text-accent" />
<h1 className="text-2xl font-bold">{channel.name}</h1>
<span className="text-xs px-2 py-0.5 rounded-full bg-surface2 text-gray-400">
{GOAL_LABELS[channel.goal] || channel.goal}
</span>
</div>
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
</div>
<Link href={`/channels/${channel.id}/history`} className="btn-ghost text-sm">
<History className="w-4 h-4" />
История
</Link>
<Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
<Settings className="w-4 h-4" />
Настройки
</Link>
</div>
{/* Generator */}
<div className="card p-5 mb-6">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 className="font-semibold flex items-center gap-2">
<Wand2 className="w-4 h-4 text-accent" />
Сгенерировать пост
</h2>
<button
onClick={fetchIdeas}
disabled={loadingIdeas}
className="text-xs inline-flex items-center gap-1 text-accent hover:underline disabled:opacity-50"
>
{loadingIdeas ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
Идеи тем
</button>
</div>
{/* Список идей */}
{showIdeas && ideas.length > 0 && (
<div className="mb-3 p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-accent uppercase tracking-wide">Идеи для постов</span>
<button onClick={() => setShowIdeas(false)} className="text-xs text-gray-500 hover:text-gray-300">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-1">
{ideas.map((idea, i) => (
<button
key={i}
onClick={() => { 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}
</button>
))}
</div>
</div>
)}
<textarea
className="input min-h-[80px] mb-3"
value={topic}
onChange={e => setTopic(e.target.value)}
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
disabled={generating}
/>
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-gray-500">
ИИ напишет пост в стиле твоего канала с учётом примеров
</div>
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
{generating ? (
<><Loader2 className="w-4 h-4 animate-spin" />Генерирую...</>
) : (
<><Wand2 className="w-4 h-4" />Сгенерировать</>
)}
</button>
</div>
{error && (
<div className="mt-3 text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
{error}
</div>
)}
</div>
{/* Result */}
{post && (
<div className="card p-5 mb-4">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="font-semibold flex items-center gap-2">
Результат
{(transforming || genImage) && <Loader2 className="w-4 h-4 animate-spin text-accent" />}
</h3>
<div className="flex items-center gap-2 flex-wrap">
{tokens && (
<span className="text-xs text-gray-500">{tokens.in}/{tokens.out} ток.</span>
)}
<button
onClick={() => setEditing(v => !v)}
className="btn-ghost text-sm py-1"
>
<Pencil className="w-4 h-4" /> {editing ? 'Готово' : 'Править'}
</button>
<button
onClick={() => generate(true)}
disabled={generating}
className="btn-ghost text-sm py-1"
title="Сгенерировать ещё один вариант"
>
<RefreshCw className="w-4 h-4" /> Ещё вариант
</button>
<button onClick={copy} className="btn-ghost text-sm py-1">
{copied ? (
<><Check className="w-4 h-4 text-accent" /> Скопировано</>
) : (
<><Copy className="w-4 h-4" /> Копировать</>
)}
</button>
</div>
</div>
{/* Сам пост — редактируемый или нет */}
{editing ? (
<textarea
value={post}
onChange={e => setPost(e.target.value)}
className="input min-h-[200px] font-mono text-sm leading-relaxed"
autoFocus
/>
) : (
<div className="bg-surface2 border border-border rounded-lg p-4 whitespace-pre-wrap text-sm leading-relaxed">
{post}
</div>
)}
{/* Картинка к посту */}
{image && (
<div className="mt-4 relative">
<img src={image} alt="" className="w-full rounded-lg" />
<button
onClick={() => setImage(null)}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Кнопка генерации картинки */}
{!image && (
<div className="mt-4">
<button
onClick={generateImage}
disabled={genImage}
className="w-full inline-flex items-center justify-center gap-2 py-2.5 rounded-lg border border-dashed border-border hover:border-accent hover:bg-accent/5 text-sm text-gray-400 hover:text-accent transition-colors disabled:opacity-50"
>
{genImage ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
) : (
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
)}
</button>
</div>
)}
{/* Публикация */}
<div className="mt-5 pt-4 border-t border-border">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Публикация</div>
<div className="flex flex-wrap gap-2">
<button
onClick={publishNow}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent hover:bg-accent/90 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{publishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{publishing ? 'Публикую...' : 'Опубликовать сейчас'}
</button>
<button
onClick={() => setShowScheduler(v => !v)}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg border border-border bg-surface2 hover:border-accent text-sm transition-colors disabled:opacity-50"
>
<Clock className="w-4 h-4" />
Запланировать
</button>
<button
onClick={() => savePost('draft').then(() => loadHistory())}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg border border-border bg-surface2 hover:border-accent text-sm transition-colors disabled:opacity-50"
>
Сохранить черновик
</button>
</div>
{/* Планировщик */}
{showScheduler && (
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
<label className="label text-xs">Время публикации (МСК)</label>
<div className="flex gap-2">
<input
type="datetime-local"
value={scheduleAt}
onChange={e => setScheduleAt(e.target.value)}
className="input text-sm flex-1"
min={new Date(Date.now() + 60000).toISOString().slice(0, 16)}
/>
<button onClick={schedule} disabled={!scheduleAt || publishing} className="btn-primary text-sm">
{publishing ? '...' : 'Запланировать'}
</button>
</div>
</div>
)}
</div>
{/* Трансформации */}
<div className="mt-5 pt-4 border-t border-border">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Переработать</div>
<div className="flex flex-wrap gap-2">
{TRANSFORMS.map(({ action, label, icon: Icon, desc }) => (
<button
key={action}
onClick={() => transform(action)}
disabled={transforming || generating}
title={desc}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-surface2 hover:border-accent hover:bg-accent/5 text-sm transition-colors disabled:opacity-50"
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
</div>
)}
{/* История вариантов */}
{variants.length > 0 && (
<div className="card p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-sm">Предыдущие варианты ({variants.length})</h3>
<button onClick={() => setVariants([])} className="text-xs text-gray-500 hover:text-red-400">
Очистить
</button>
</div>
<div className="space-y-2">
{variants.map((v, i) => (
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
<div className="flex-1 text-xs text-gray-400 line-clamp-3 whitespace-pre-wrap">
{v.content}
</div>
<button
onClick={() => restoreVariant(i)}
className="text-xs text-accent hover:underline shrink-0"
title="Сделать активным"
>
вернуть
</button>
</div>
))}
</div>
</div>
)}
{/* История постов */}
{history.length > 0 && (
<div className="card p-5 mt-4">
<h3 className="font-semibold text-sm mb-3">История постов канала</h3>
<div className="space-y-2">
{history.map(p => {
const statusColors = {
draft: 'bg-gray-500/15 text-gray-400',
scheduled: 'bg-blue-500/15 text-blue-400',
published: 'bg-accent/15 text-accent',
failed: 'bg-red-500/15 text-red-400',
};
const statusLabels = { draft: 'Черновик', scheduled: 'Запланирован', published: 'Опубликован', failed: 'Ошибка' };
return (
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
{p.image_url && (
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
<div className="flex items-center gap-2 text-xs">
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
{statusLabels[p.status] || p.status}
</span>
{p.scheduled_at && p.status === 'scheduled' && (
<span className="text-blue-400"> {new Date(p.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.published_at && (
<span className="text-gray-500">{new Date(p.published_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{!p.scheduled_at && !p.published_at && (
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.error && (
<span className="text-red-400 truncate">{p.error}</span>
)}
</div>
</div>
<button
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
className="text-xs text-accent hover:underline shrink-0"
>
открыть
</button>
</div>
);
})}
</div>
</div>
)}
</main>
);
}