forked from admin/zeropost-tool
715 lines
29 KiB
JavaScript
715 lines
29 KiB
JavaScript
'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>
|
||
);
|
||
}
|