feat: P2 PostPreview — TG/VK/MAX preview with char counter, integrated in ChannelView

This commit is contained in:
Nik (Claude)
2026-06-08 10:57:38 +03:00
parent 999119d58d
commit 0c8ca23015
2 changed files with 338 additions and 1 deletions
+325
View File
@@ -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, '&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>
);
}