merge: resolve ChannelView icon conflict, keep History + Search/Camera/ExternalLink/Link2
This commit is contained in:
+330
-47
@@ -4,8 +4,17 @@ 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
|
||||
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History,
|
||||
Search, Camera, ExternalLink, Link2
|
||||
} from 'lucide-react';
|
||||
import PhotoSearchModal from './PhotoSearchModal';
|
||||
import PostPreview from './PostPreview';
|
||||
import PostTemplates from './PostTemplates';
|
||||
import ChannelAnalytics from './ChannelAnalytics';
|
||||
import FromUrlModal from './FromUrlModal';
|
||||
import PollModal from './PollModal';
|
||||
import HashtagSuggest from './HashtagSuggest';
|
||||
import InboxTab from './InboxTab';
|
||||
|
||||
const GOAL_LABELS = {
|
||||
educational: 'Обучение', news: 'Новости',
|
||||
@@ -22,8 +31,19 @@ const TRANSFORMS = [
|
||||
{ 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 [customPrompt, setCustomPrompt] = useState('');
|
||||
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [post, setPost] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
@@ -36,8 +56,16 @@ export default function ChannelView({ channel }) {
|
||||
|
||||
// Картинка
|
||||
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 [showFromUrl, setShowFromUrl] = useState(false);
|
||||
const [showPoll, setShowPoll] = useState(false);
|
||||
const [batchCount, setBatchCount] = useState(3);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
// Трансформации
|
||||
const [transforming, setTransforming] = useState(false);
|
||||
|
||||
@@ -69,12 +97,12 @@ export default function ChannelView({ channel }) {
|
||||
// Сохранение и публикация
|
||||
const [savedPostId, setSavedPostId] = useState(null);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('generate'); // generate | analytics
|
||||
const [showScheduler, setShowScheduler] = useState(false);
|
||||
const [scheduleAt, setScheduleAt] = useState('');
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
// Подгрузка истории при монтировании
|
||||
useEffect(() => { loadHistory(); }, []);
|
||||
|
||||
async function loadHistory() {
|
||||
@@ -86,6 +114,30 @@ export default function ChannelView({ channel }) {
|
||||
} 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);
|
||||
}
|
||||
|
||||
function applyFromUrl({ content, imageUrl, title }) {
|
||||
setPost(content);
|
||||
if (imageUrl) setImage(imageUrl);
|
||||
if (title && !topic.trim()) setTopic(title.slice(0, 120));
|
||||
setSavedPostId(null);
|
||||
}
|
||||
|
||||
async function savePost(status = 'draft', scheduledAt = null) {
|
||||
if (!post) return;
|
||||
setPublishing(true);
|
||||
@@ -93,12 +145,12 @@ export default function ChannelView({ channel }) {
|
||||
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,
|
||||
channel_id: channel.id, content: post,
|
||||
image_url: image, image_credit: imageCredit,
|
||||
topic: topic.trim(), status, scheduled_at: scheduledAt,
|
||||
}),
|
||||
});
|
||||
@@ -107,11 +159,15 @@ export default function ChannelView({ channel }) {
|
||||
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 }),
|
||||
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 || 'Ошибка');
|
||||
}
|
||||
@@ -137,6 +193,7 @@ export default function ChannelView({ channel }) {
|
||||
setPost(null);
|
||||
setSavedPostId(null);
|
||||
setImage(null);
|
||||
setImageCredit(null);
|
||||
setTopic('');
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setPublishing(false); }
|
||||
@@ -151,9 +208,18 @@ export default function ChannelView({ channel }) {
|
||||
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;
|
||||
@@ -168,10 +234,18 @@ export default function ChannelView({ channel }) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
|
||||
customPrompt: customPrompt.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
const job = await createRes.json();
|
||||
if (!createRes.ok) throw new Error(job.error || 'Ошибка');
|
||||
if (!createRes.ok) {
|
||||
if (job.code === 'INSUFFICIENT_CREDITS') {
|
||||
throw new Error(`Недостаточно кредитов: нужно ${job.cost}, есть ${job.credits}. Пополните баланс на странице тарифов.`);
|
||||
}
|
||||
throw new Error(job.error || 'Ошибка');
|
||||
}
|
||||
// Триггер обновления баланса в header
|
||||
if (job.credits_after !== null) window.dispatchEvent(new Event('credits-updated'));
|
||||
|
||||
let final;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
@@ -183,14 +257,14 @@ export default function ChannelView({ channel }) {
|
||||
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 }]);
|
||||
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||
}
|
||||
|
||||
setPost(final.result);
|
||||
setTokens({ in: final.tokens_in, out: final.tokens_out });
|
||||
setImage(null); // сбрасываем картинку при новом посте
|
||||
setImage(null);
|
||||
setImageCredit(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -210,10 +284,10 @@ export default function ChannelView({ channel }) {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Ошибка');
|
||||
// Сохраняем текущий в варианты
|
||||
setVariants(v => [...v, { content: post, tokens, image }]);
|
||||
setVariants(v => [...v, { content: post, tokens, image, imageCredit }]);
|
||||
setPost(data.content);
|
||||
setImage(null);
|
||||
setImageCredit(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -234,6 +308,7 @@ export default function ChannelView({ channel }) {
|
||||
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 {
|
||||
@@ -245,12 +320,13 @@ export default function ChannelView({ channel }) {
|
||||
const v = variants[idx];
|
||||
setVariants(arr => {
|
||||
const next = arr.filter((_, i) => i !== idx);
|
||||
next.push({ content: post, tokens, image });
|
||||
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() {
|
||||
@@ -271,7 +347,7 @@ export default function ChannelView({ channel }) {
|
||||
<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}
|
||||
{(channel.goal || '').split(',').map(g => GOAL_LABELS[g.trim()] || g.trim()).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
|
||||
@@ -286,6 +362,26 @@ export default function ChannelView({ channel }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Вкладки */}
|
||||
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border self-start mb-2">
|
||||
{[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (
|
||||
<button key={id} onClick={() => setActiveTab(id)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
${activeTab===id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<ChannelAnalytics channelId={channel.id} channelName={channel.tg_username} />
|
||||
)}
|
||||
|
||||
{activeTab === 'inbox' && (
|
||||
<InboxTab channel={channel} />
|
||||
)}
|
||||
|
||||
{activeTab === 'generate' && <>
|
||||
{/* Generator */}
|
||||
<div className="card p-5 mb-6">
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
@@ -293,17 +389,60 @@ export default function ChannelView({ channel }) {
|
||||
<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 className="flex items-center gap-3">
|
||||
<PostTemplates onSelect={applyTemplate} disabled={generating} />
|
||||
<button
|
||||
onClick={() => setShowFromUrl(true)}
|
||||
className="text-xs inline-flex items-center gap-1 text-accent hover:underline"
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" />
|
||||
По ссылке
|
||||
</button>
|
||||
{channel.platform === 'telegram' && (
|
||||
<button
|
||||
onClick={() => setShowPoll(true)}
|
||||
className="text-xs inline-flex items-center gap-1 text-gray-400 hover:text-accent transition-colors"
|
||||
>
|
||||
<span>📊</span>
|
||||
Опрос
|
||||
</button>
|
||||
)}
|
||||
{/* Batch-генерация черновиков */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/channels/${channel.id}/drafts/generate?count=${batchCount}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
}).then(r => r.json());
|
||||
if (res.ok) alert(`✅ Генерирую ${batchCount} черновиков — через несколько минут появятся в /drafts`);
|
||||
else alert(res.error || 'Ошибка');
|
||||
} catch { alert('Ошибка'); }
|
||||
setBatchLoading(false);
|
||||
}}
|
||||
disabled={batchLoading}
|
||||
className="text-xs inline-flex items-center gap-1 text-purple-400 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
<span>{batchLoading ? '⏳' : '⚡'}</span>
|
||||
Авто ×
|
||||
</button>
|
||||
<select value={batchCount} onChange={e => setBatchCount(+e.target.value)}
|
||||
className="text-xs bg-surface2 border border-border rounded px-1 py-0.5 text-gray-400">
|
||||
{[1,2,3,5,7,10].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
@@ -327,15 +466,49 @@ export default function ChannelView({ channel }) {
|
||||
)}
|
||||
|
||||
<textarea
|
||||
className="input min-h-[80px] mb-3"
|
||||
className="input min-h-[80px] mb-2"
|
||||
value={topic}
|
||||
onChange={e => setTopic(e.target.value)}
|
||||
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
|
||||
disabled={generating}
|
||||
/>
|
||||
|
||||
{/* Дополнительные инструкции */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCustomPrompt(v => !v)}
|
||||
className="text-xs text-gray-500 hover:text-accent flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<span>{showCustomPrompt ? '▾' : '▸'}</span>
|
||||
Дополнительные инструкции для AI
|
||||
{customPrompt.trim() && <span className="ml-1 w-1.5 h-1.5 rounded-full bg-accent inline-block" />}
|
||||
</button>
|
||||
{showCustomPrompt && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<textarea
|
||||
rows={3}
|
||||
className="input w-full text-sm resize-none"
|
||||
placeholder={`Например: «Сделай акцент на кейсах из сельского хозяйства» или «Добавь призыв подписаться в конце»`}
|
||||
value={customPrompt}
|
||||
onChange={e => setCustomPrompt(e.target.value)}
|
||||
disabled={generating}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Перебивает стиль канала для этой генерации.
|
||||
{channel.ai_style_prompt && ' Канальный промт также будет применён.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
ИИ напишет пост в стиле твоего канала с учётом примеров
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<div>ИИ напишет пост в стиле твоего канала с учётом примеров</div>
|
||||
<div className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">2 кр — текст</span>
|
||||
{channel.image_enabled && <span className="px-1.5 py-0.5 rounded bg-surface2 text-[11px]">+ 5 кр — картинка</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => generate(false)} disabled={generating || !topic.trim()} className="btn-primary">
|
||||
{generating ? (
|
||||
@@ -354,7 +527,8 @@ export default function ChannelView({ channel }) {
|
||||
|
||||
{/* Result */}
|
||||
{post && (
|
||||
<div className="card p-5 mb-4">
|
||||
<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">
|
||||
Результат
|
||||
@@ -388,7 +562,6 @@ export default function ChannelView({ channel }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Сам пост — редактируемый или нет */}
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={post}
|
||||
@@ -402,32 +575,93 @@ export default function ChannelView({ channel }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Картинка к посту */}
|
||||
{/* Хештеги */}
|
||||
<HashtagSuggest
|
||||
channelId={channel.id}
|
||||
postText={post}
|
||||
onAppend={text => setPost(p => (p || '') + text)}
|
||||
/>
|
||||
{image && (
|
||||
<div className="mt-4 relative">
|
||||
<img src={image} alt="" className="w-full rounded-lg" />
|
||||
<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={() => setImage(null)}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-lg bg-black/60 hover:bg-black/80 text-white"
|
||||
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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<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-4">
|
||||
{/* Если уже есть картинка — даём ещё раз поменять */}
|
||||
{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="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"
|
||||
className="btn-ghost text-xs py-1"
|
||||
>
|
||||
{genImage ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Генерирую картинку... (~30 сек)</>
|
||||
) : (
|
||||
<><ImageIcon className="w-4 h-4" /> Сгенерировать картинку</>
|
||||
)}
|
||||
{genImage ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Camera className="w-3.5 h-3.5" />}
|
||||
Заменить AI-картинкой
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -461,7 +695,6 @@ export default function ChannelView({ channel }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Планировщик */}
|
||||
{showScheduler && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
|
||||
<label className="label text-xs">Время публикации (МСК)</label>
|
||||
@@ -500,8 +733,47 @@ export default function ChannelView({ channel }) {
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* From URL modal */}
|
||||
<FromUrlModal
|
||||
open={showFromUrl}
|
||||
channelId={channel.id}
|
||||
onClose={() => setShowFromUrl(false)}
|
||||
onApply={applyFromUrl}
|
||||
/>
|
||||
|
||||
{showPoll && (
|
||||
<PollModal
|
||||
channel={channel}
|
||||
onClose={() => setShowPoll(false)}
|
||||
onPublished={r => {
|
||||
setShowPoll(false);
|
||||
// Уведомление
|
||||
if (r.scheduled) alert(`Опрос запланирован на ${new Date(r.scheduled_at).toLocaleString('ru-RU')}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Photo search modal */}
|
||||
<PhotoSearchModal
|
||||
open={showPhotoSearch}
|
||||
onClose={() => setShowPhotoSearch(false)}
|
||||
topic={topic}
|
||||
post={post}
|
||||
onPick={applyPhotoPick}
|
||||
/>
|
||||
|
||||
{/* История вариантов */}
|
||||
{variants.length > 0 && (
|
||||
<div className="card p-5">
|
||||
@@ -545,11 +817,11 @@ export default function ChannelView({ channel }) {
|
||||
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" />
|
||||
<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">
|
||||
<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>
|
||||
@@ -562,13 +834,23 @@ export default function ChannelView({ channel }) {
|
||||
{!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); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
|
||||
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"
|
||||
>
|
||||
↑ открыть
|
||||
@@ -579,6 +861,7 @@ export default function ChannelView({ channel }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</> }
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user