feat: P2 PostPreview — TG/VK/MAX preview with char counter, integrated in ChannelView
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
'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, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// **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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user