Files
zeropost-tool/components/PostPreview.js
T

326 lines
14 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';
/**
* PostPreview — рендерит пост так, как он будет выглядеть в TG / VK / MAX.
* Props:
* text — текст поста (Markdown-разметка TG)
* imageUrl — URL картинки (опционально)
* platform — 'telegram' | 'vk' | 'max'
* channelName — название канала (для шапки)
*/
import { useState } from 'react';
import { Eye, EyeOff, MessageCircle, Heart, Share2, Bookmark,
ThumbsUp, BarChart2, AlertCircle } from 'lucide-react';
// ── Лимиты платформ ───────────────────────────────────────────────────────────
const LIMITS = {
telegram: { text: 4096, caption: 1024, label: 'Telegram' },
vk: { text: 16384, caption: 2048, label: 'ВКонтакте' },
max: { text: 4096, caption: 1024, label: 'MAX' },
};
// ── Парсер разметки → HTML ────────────────────────────────────────────────────
function parseTgMarkdown(text) {
// Экранируем HTML-спецсимволы
let s = text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// **bold** или __bold__
s = s.replace(/\*\*(.+?)\*\*/gs, '<strong>$1</strong>');
s = s.replace(/__(.+?)__/gs, '<strong>$1</strong>');
// _italic_ или *italic*
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/gs, '<em>$1</em>');
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/gs, '<em>$1</em>');
// `code`
s = s.replace(/`([^`]+)`/g, '<code class="tg-code">$1</code>');
// ```block```
s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, '<pre class="tg-pre">$1</pre>');
// [text](url)
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,
'<a href="$2" class="tg-link" target="_blank" rel="noopener">$1</a>');
// Переносы строк → <br>
s = s.replace(/\n/g, '<br>');
return s;
}
function parseVkMarkdown(text) {
// VK не поддерживает Markdown — убираем разметку, оставляем текст
let s = text
.replace(/\*\*(.+?)\*\*/gs, '$1')
.replace(/__(.+?)__/gs, '$1')
.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/gs, '$1')
.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/gs, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/```[\w]*\n?([\s\S]*?)```/g, '$1')
.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g, '$1 ($2)')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return s;
}
function renderMarkdown(text, platform) {
if (!text) return '';
if (platform === 'vk') return parseVkMarkdown(text);
return parseTgMarkdown(text); // telegram + max
}
// ── Счётчик символов ──────────────────────────────────────────────────────────
function CharCounter({ text, imageUrl, platform }) {
const limits = LIMITS[platform] || LIMITS.telegram;
const limit = imageUrl ? limits.caption : limits.text;
const len = (text || '').length;
const pct = Math.min(len / limit, 1);
const over = len > limit;
const color = over ? 'text-red-400' : pct > 0.85 ? 'text-yellow-400' : 'text-text-mute';
return (
<div className={`flex items-center gap-2 text-xs ${color}`}>
{over && <AlertCircle className="w-3 h-3 shrink-0" />}
<span>{len} / {limit}{imageUrl ? ' (caption)' : ''}</span>
{over && <span className="font-medium">превышен лимит</span>}
</div>
);
}
// ── TG Preview ────────────────────────────────────────────────────────────────
function TelegramPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'telegram');
return (
<div className="bg-[#17212b] rounded-2xl overflow-hidden text-[#e0e0e0] text-sm font-sans max-w-sm mx-auto shadow-xl">
{/* Header */}
<div className="px-4 py-2 bg-[#1c2733] flex items-center gap-3 border-b border-white/5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'Z').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-white text-xs">{channelName || 'Канал'}</div>
<div className="text-[10px] text-[#5d8299]">только что</div>
</div>
</div>
{/* Контент */}
<div className="p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-xl mb-2 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
<div
className="leading-[1.5] break-words text-[13px]"
style={{ color: '#e0e0e0' }}
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
</div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center justify-between text-[#5d8299] text-[11px]">
<div className="flex items-center gap-3">
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> 1.2K</span>
<span className="flex items-center gap-1"><Share2 className="w-3 h-3" /> 14</span>
</div>
<div className="flex items-center gap-2">
<MessageCircle className="w-3.5 h-3.5" />
<Bookmark className="w-3.5 h-3.5" />
</div>
</div>
{/* TG inline styles */}
<style>{`
.tg-code { background: rgba(255,255,255,0.1); border-radius: 3px; padding: 1px 4px; font-family: monospace; font-size: 12px; }
.tg-pre { background: rgba(255,255,255,0.07); border-radius: 6px; padding: 8px; font-family: monospace; font-size: 11px; white-space: pre-wrap; margin: 6px 0; }
.tg-link { color: #5bc8f5; text-decoration: none; }
.tg-link:hover { text-decoration: underline; }
`}</style>
</div>
);
}
// ── VK Preview ────────────────────────────────────────────────────────────────
function VkPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'vk');
return (
<div className="bg-[#f2f3f5] rounded-2xl overflow-hidden text-[#2c2d2e] text-sm font-sans max-w-sm mx-auto shadow-lg">
{/* Header */}
<div className="px-4 py-3 bg-white flex items-center gap-3 border-b border-[#e0e0e0]">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-[#5181b8] to-[#2c5999] flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'K').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-[#2c2d2e] text-xs">{channelName || 'Сообщество'}</div>
<div className="text-[10px] text-[#818c99]">только что</div>
</div>
<div className="ml-auto text-[#818c99] text-[10px]">···</div>
</div>
{/* Контент */}
<div className="px-4 py-3 bg-white">
<div
className="leading-[1.55] break-words text-[13px] text-[#2c2d2e]"
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-lg mt-3 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-white border-t border-[#e7e8ec] flex items-center gap-4 text-[#818c99] text-xs">
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<ThumbsUp className="w-3.5 h-3.5" /> 24
</button>
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<MessageCircle className="w-3.5 h-3.5" /> 3
</button>
<button className="flex items-center gap-1 hover:text-[#5181b8]">
<Share2 className="w-3.5 h-3.5" /> Поделиться
</button>
<span className="ml-auto flex items-center gap-1">
<Eye className="w-3 h-3" /> 891
</span>
</div>
</div>
);
}
// ── MAX Preview ───────────────────────────────────────────────────────────────
function MaxPreview({ text, imageUrl, channelName }) {
const html = renderMarkdown(text, 'max');
return (
<div className="bg-[#1a1a1a] rounded-2xl overflow-hidden text-[#d0d0d0] text-sm font-sans max-w-sm mx-auto shadow-xl">
{/* Header */}
<div className="px-4 py-2.5 bg-[#222] flex items-center gap-3 border-b border-white/5">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-orange-400 to-orange-600 flex items-center justify-center text-white text-xs font-bold shrink-0">
{(channelName || 'M').slice(0, 1).toUpperCase()}
</div>
<div>
<div className="font-semibold text-white text-xs">{channelName || 'Канал MAX'}</div>
<div className="text-[10px] text-[#666]">только что</div>
</div>
</div>
{/* Контент */}
<div className="p-3">
{imageUrl && (
<img
src={imageUrl}
alt=""
className="w-full rounded-xl mb-2 max-h-56 object-cover"
referrerPolicy="no-referrer"
onError={e => { e.target.style.display = 'none'; }}
/>
)}
<div
className="leading-[1.5] break-words text-[13px] text-[#d0d0d0]"
dangerouslySetInnerHTML={{ __html: html || '<span style="opacity:0.3">Текст поста появится здесь…</span>' }}
/>
</div>
{/* Footer */}
<div className="px-3 pb-3 flex items-center gap-4 text-[#555] text-[11px]">
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> 432</span>
<span className="flex items-center gap-1"><Heart className="w-3 h-3" /> 18</span>
<span className="flex items-center gap-1"><BarChart2 className="w-3 h-3" /> Опрос</span>
</div>
</div>
);
}
// ── Главный экспорт ───────────────────────────────────────────────────────────
const PLATFORM_ORDER = ['telegram', 'vk', 'max'];
const PLATFORM_LABELS = { telegram: 'TG', vk: 'VK', max: 'MAX' };
export default function PostPreview({ text, imageUrl, platform: defaultPlatform = 'telegram', channelName }) {
const [visible, setVisible] = useState(true);
const [platform, setPlatform] = useState(defaultPlatform);
if (!visible) {
return (
<button
onClick={() => setVisible(true)}
className="flex items-center gap-1.5 text-xs text-text-mute hover:text-text transition-colors"
>
<Eye className="w-3.5 h-3.5" /> Показать превью
</button>
);
}
return (
<div className="flex flex-col gap-3">
{/* Тулбар */}
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-text-soft uppercase tracking-wide">Превью</span>
<div className="flex items-center gap-1">
{/* Переключатель платформы */}
<div className="flex rounded-lg overflow-hidden border border-border text-xs">
{PLATFORM_ORDER.map(p => (
<button
key={p}
onClick={() => setPlatform(p)}
className={`px-2.5 py-1 font-medium transition-colors
${platform === p
? 'bg-accent text-white'
: 'bg-surface2 text-text-mute hover:text-text'
}`}
>
{PLATFORM_LABELS[p]}
</button>
))}
</div>
{/* Скрыть */}
<button
onClick={() => setVisible(false)}
className="btn-ghost p-1.5 rounded-lg"
title="Скрыть превью"
>
<EyeOff className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Счётчик символов */}
<CharCounter text={text} imageUrl={imageUrl} platform={platform} />
{/* Превью платформы */}
{platform === 'telegram' && (
<TelegramPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{platform === 'vk' && (
<VkPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{platform === 'max' && (
<MaxPreview text={text} imageUrl={imageUrl} channelName={channelName} />
)}
{/* Лимит */}
<div className="text-[10px] text-text-mute text-center">
{LIMITS[platform].label}: текст до {LIMITS[platform].text.toLocaleString()} симв.
{', caption (с фото) до '}{LIMITS[platform].caption.toLocaleString()} симв.
</div>
</div>
);
}