Files
postcast-tool/components/ChannelView.js
T

715 lines
29 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, Send, Clock, Search, Camera, ExternalLink
} from 'lucide-react';
import PhotoSearchModal from './PhotoSearchModal';
import PostPreview from './PostPreview';
import PostTemplates from './PostTemplates';
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: 'Адаптировать под ВКонтакте' },
];
// Хвостовая подпись «📷 Фото: domain» — добавляется к посту, можно убрать
function buildCaption(domain) {
return domain ? `\n\n📷 Фото: ${domain}` : '';
}
// Удаляет существующую подпись из текста поста (по любому домену)
function stripCaption(text) {
return (text || '').replace(/\n{1,2}📷\s*Фото:\s*[^\n]+\s*$/u, '').trimEnd();
}
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 [imageCredit, setImageCredit] = useState(null); // { domain, sourceUrl, title } | null
const [genImage, setGenImage] = useState(false);
// Photo search modal
const [showPhotoSearch, setShowPhotoSearch] = 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); }
}
function clearImage() {
setImage(null);
setImageCredit(null);
// Если в посте была подпись «📷 Фото: …» — убираем её при удалении фото
if (post) setPost(p => stripCaption(p));
}
function applyPhotoPick({ imageUrl, credit }) {
setImage(imageUrl);
setImageCredit(credit || null);
// Подменяем (или добавляем) caption
if (post && credit?.domain) {
setPost(p => stripCaption(p) + buildCaption(credit.domain));
}
setShowPhotoSearch(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, image_credit: imageCredit,
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,
image_credit: imageCredit,
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);
setImageCredit(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);
setImageCredit(null);
setTopic('');
}
function applyTemplate({ topicHint, structure }) {
// Если тема не задана — подставляем подсказку
if (!topic.trim()) setTopic(topicHint);
// В textarea вставляем структуру как отправную точку
setPost(structure);
setSavedPostId(null);
}
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 || 'Генерация упала');
if (asVariant && post) {
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
}
setPost(final.result);
setTokens({ in: final.tokens_in, out: final.tokens_out });
setImage(null);
setImageCredit(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, imageCredit }]);
setPost(data.content);
setImage(null);
setImageCredit(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);
setImageCredit(null); // сгенерированная — без credit'а
} 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, imageCredit });
return next;
});
setPost(v.content);
setTokens(v.tokens);
setImage(v.image);
setImageCredit(v.imageCredit || null);
}
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}/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>
<div className="flex items-center gap-3">
<PostTemplates onSelect={applyTemplate} disabled={generating} />
<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>
</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="grid lg:grid-cols-[1fr_360px] gap-4 mb-4 items-start">
<div className="card p-5">
<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">
<div className="relative">
<img src={image} alt="" className="w-full rounded-lg" referrerPolicy="no-referrer" />
<button
onClick={clearImage}
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
title="Убрать картинку"
>
<X className="w-4 h-4" />
</button>
</div>
{imageCredit?.domain && (
<div className="mt-2 flex items-center justify-between flex-wrap gap-2 text-xs">
<div className="text-gray-500">
📷 Фото:{' '}
{imageCredit.sourceUrl ? (
<a
href={imageCredit.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline inline-flex items-center gap-1"
>
{imageCredit.domain}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-accent">{imageCredit.domain}</span>
)}
</div>
<button
onClick={() => setPost(p => stripCaption(p))}
className="text-gray-500 hover:text-gray-300"
title="Убрать подпись «📷 Фото: …» из текста поста (саму картинку оставить)"
>
убрать подпись из поста
</button>
</div>
)}
</div>
)}
{/* Кнопки получения картинки */}
{!image && (
<div className="mt-4 grid sm:grid-cols-2 gap-2">
<button
onClick={() => setShowPhotoSearch(true)}
className="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"
>
<Search className="w-4 h-4" /> Найти фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="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 сек)</>
) : (
<><Camera className="w-4 h-4" /> Сгенерировать (AI)</>
)}
</button>
</div>
)}
{/* Если уже есть картинка — даём ещё раз поменять */}
{image && (
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<button
onClick={() => setShowPhotoSearch(true)}
className="btn-ghost text-xs py-1"
>
<Search className="w-3.5 h-3.5" /> Другое фото
</button>
<button
onClick={generateImage}
disabled={genImage}
className="btn-ghost text-xs py-1"
>
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
Заменить AI-картинкой
</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>
{/* Правая колонка — превью */}
<div className="card p-4 sticky top-20">
<PostPreview
text={post}
imageUrl={image}
platform={channel.platform || 'telegram'}
channelName={channel.name}
/>
</div>
</div>
)}
{/* Photo search modal */}
<PhotoSearchModal
open={showPhotoSearch}
onClose={() => setShowPhotoSearch(false)}
topic={topic}
post={post}
onPick={applyPhotoPick}
/>
{/* История вариантов */}
{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" referrerPolicy="no-referrer" />
)}
<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 flex-wrap">
<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.image_credit?.domain && (
<span className="text-gray-500">📷 {p.image_credit.domain}</span>
)}
{p.error && (
<span className="text-red-400 truncate">{p.error}</span>
)}
</div>
</div>
<button
onClick={() => {
setPost(p.content);
setImage(p.image_url);
setImageCredit(p.image_credit || null);
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>
);
}