remake send push systems
This commit is contained in:
@@ -1,9 +1,34 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
||||
import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X } from 'lucide-react'
|
||||
|
||||
// Telegram supported tags for reference
|
||||
const TELEGRAM_TAGS = [
|
||||
{ tag: '<b>', description: 'Жирный текст', example: '<b>жирный</b>' },
|
||||
{ tag: '<i>', description: 'Курсив', example: '<i>курсив</i>' },
|
||||
{ tag: '<u>', description: 'Подчёркнутый', example: '<u>подчёркнутый</u>' },
|
||||
{ tag: '<s>', description: 'Зачёркнутый', example: '<s>зачёркнутый</s>' },
|
||||
{ tag: '<code>', description: 'Моноширинный', example: '<code>код</code>' },
|
||||
{ tag: '<pre>', description: 'Блок кода', example: '<pre>блок кода</pre>' },
|
||||
{ tag: '<a>', description: 'Ссылка', example: '<a href="url">текст</a>' },
|
||||
{ tag: '<tg-spoiler>', description: 'Спойлер', example: '<tg-spoiler>скрытый текст</tg-spoiler>' },
|
||||
{ tag: '<blockquote>', description: 'Цитата', example: '<blockquote>цитата</blockquote>' },
|
||||
]
|
||||
|
||||
// Convert Telegram HTML to web-safe HTML for preview
|
||||
function telegramToHtml(text: string): string {
|
||||
let html = text
|
||||
// Convert tg-spoiler to span with blur effect
|
||||
.replace(/<tg-spoiler>/g, '<span class="tg-spoiler">')
|
||||
.replace(/<\/tg-spoiler>/g, '</span>')
|
||||
// Convert newlines to <br> tags (but not inside <pre> blocks)
|
||||
.replace(/\n/g, '<br>')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
export function AdminBroadcastPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -12,11 +37,26 @@ export function AdminBroadcastPage() {
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||
const [marathonSearch, setMarathonSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
|
||||
const [showTagsHelp, setShowTagsHelp] = useState(false)
|
||||
|
||||
// Undo/Redo history
|
||||
const [history, setHistory] = useState<string[]>([''])
|
||||
const [historyIndex, setHistoryIndex] = useState(0)
|
||||
const isUndoRedo = useRef(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Load marathons on mount and when switching to marathon target
|
||||
useEffect(() => {
|
||||
if (targetType === 'marathon') {
|
||||
// Always load marathons on mount so they're ready when user switches
|
||||
loadMarathons()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (targetType === 'marathon' && marathons.length === 0) {
|
||||
loadMarathons()
|
||||
}
|
||||
}, [targetType])
|
||||
@@ -24,15 +64,86 @@ export function AdminBroadcastPage() {
|
||||
const loadMarathons = async () => {
|
||||
setLoadingMarathons(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(0, 100)
|
||||
setMarathons(data.filter(m => m.status === 'active'))
|
||||
const data = await adminApi.listMarathons(0, 200)
|
||||
console.log('Loaded marathons:', data)
|
||||
setMarathons(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
toast.error('Ошибка загрузки марафонов')
|
||||
} finally {
|
||||
setLoadingMarathons(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle message change with history
|
||||
const handleMessageChange = useCallback((newValue: string) => {
|
||||
if (isUndoRedo.current) {
|
||||
isUndoRedo.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setMessage(newValue)
|
||||
|
||||
// Add to history (debounced - only if different from last entry)
|
||||
setHistory(prev => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1)
|
||||
if (newHistory[newHistory.length - 1] !== newValue) {
|
||||
return [...newHistory, newValue]
|
||||
}
|
||||
return newHistory
|
||||
})
|
||||
setHistoryIndex(prev => prev + 1)
|
||||
}, [historyIndex])
|
||||
|
||||
// Undo function
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
isUndoRedo.current = true
|
||||
const newIndex = historyIndex - 1
|
||||
setHistoryIndex(newIndex)
|
||||
setMessage(history[newIndex])
|
||||
}
|
||||
}, [history, historyIndex])
|
||||
|
||||
// Redo function
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
isUndoRedo.current = true
|
||||
const newIndex = historyIndex + 1
|
||||
setHistoryIndex(newIndex)
|
||||
setMessage(history[newIndex])
|
||||
}
|
||||
}, [history, historyIndex])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'z') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
redo()
|
||||
} else {
|
||||
undo()
|
||||
}
|
||||
} else if (e.key === 'y') {
|
||||
e.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
}, [undo, redo])
|
||||
|
||||
// Filter marathons based on search and status
|
||||
const filteredMarathons = useMemo(() => {
|
||||
return marathons.filter(m => {
|
||||
const matchesSearch = !marathonSearch ||
|
||||
m.title.toLowerCase().includes(marathonSearch.toLowerCase())
|
||||
const matchesStatus = statusFilter === 'all' || m.status === statusFilter
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
}, [marathons, marathonSearch, statusFilter])
|
||||
|
||||
const selectedMarathon = marathons.find(m => m.id === marathonId)
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) {
|
||||
toast.error('Введите сообщение')
|
||||
@@ -63,6 +174,39 @@ export function AdminBroadcastPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const config = {
|
||||
active: { label: 'Активен', class: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||
preparing: { label: 'Подготовка', class: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||
finished: { label: 'Завершён', class: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||
}
|
||||
const c = config[status as keyof typeof config] || config.finished
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${c.class}`}>
|
||||
{c.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const insertTag = (openTag: string, closeTag: string) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selectedText = message.substring(start, end)
|
||||
const newText = message.substring(0, start) + openTag + selectedText + closeTag + message.substring(end)
|
||||
|
||||
handleMessageChange(newText)
|
||||
|
||||
// Restore cursor position
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = start + openTag.length + selectedText.length
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -73,118 +217,401 @@ export function AdminBroadcastPage() {
|
||||
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||
</div>
|
||||
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Target Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Кому отправить
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType('all')
|
||||
setMarathonId(null)
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'all'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Всем пользователям</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTargetType('marathon')}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'marathon'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span className="font-medium">Участникам марафона</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marathon Selection */}
|
||||
{targetType === 'marathon' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-6">
|
||||
{/* Left Column - Editor */}
|
||||
<div className="flex-1 space-y-6">
|
||||
{/* Target Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Выберите марафон
|
||||
Кому отправить
|
||||
</label>
|
||||
{loadingMarathons ? (
|
||||
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
||||
) : (
|
||||
<select
|
||||
value={marathonId || ''}
|
||||
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTargetType('all')
|
||||
setMarathonId(null)
|
||||
}}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'all'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">Выберите марафон...</option>
|
||||
{marathons.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title} ({m.participants_count} участников)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{marathons.length === 0 && !loadingMarathons && (
|
||||
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
||||
)}
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Всем пользователям</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTargetType('marathon')}
|
||||
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||
targetType === 'marathon'
|
||||
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-5 h-5" />
|
||||
<span className="font-medium">Участникам марафона</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Сообщение
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<p className="text-gray-500">
|
||||
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
||||
</p>
|
||||
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||
{message.length} / 2000
|
||||
</p>
|
||||
{/* Marathon Selection */}
|
||||
{targetType === 'marathon' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Выберите марафон
|
||||
</label>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по названию..."
|
||||
value={marathonSearch}
|
||||
onChange={(e) => setMarathonSearch(e.target.value)}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
className="bg-dark-700/50 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="preparing">Подготовка</option>
|
||||
<option value="finished">Завершённые</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Marathon List */}
|
||||
{loadingMarathons ? (
|
||||
<div className="animate-pulse bg-dark-700 h-32 rounded-xl" />
|
||||
) : (
|
||||
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-600 bg-dark-800/50">
|
||||
{filteredMarathons.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
{marathons.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p>Не удалось загрузить марафоны</p>
|
||||
<button
|
||||
onClick={loadMarathons}
|
||||
className="text-accent-400 hover:underline"
|
||||
>
|
||||
Повторить загрузку
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
'Марафоны не найдены по фильтру'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredMarathons.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setMarathonId(marathonId === m.id ? null : m.id)}
|
||||
className={`w-full flex items-center justify-between p-3 text-left border-b border-dark-600 last:border-b-0 transition-colors ${
|
||||
marathonId === m.id
|
||||
? 'bg-accent-500/20 text-white'
|
||||
: 'hover:bg-dark-700/50 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="truncate font-medium">{m.title}</span>
|
||||
{getStatusBadge(m.status)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||
{m.participants_count} уч.
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Marathon Info */}
|
||||
{selectedMarathon && (
|
||||
<div className="p-3 bg-accent-500/10 border border-accent-500/30 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-accent-400">
|
||||
<Trophy className="w-4 h-4" />
|
||||
<span className="font-medium">{selectedMarathon.title}</span>
|
||||
{getStatusBadge(selectedMarathon.status)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMarathonId(null)}
|
||||
className="p-1 text-gray-400 hover:text-white rounded hover:bg-dark-600/50 transition-colors"
|
||||
title="Отменить выбор"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{selectedMarathon.participants_count} участников получат сообщение
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message with Tag Toolbar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
Сообщение
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowTagsHelp(!showTagsHelp)}
|
||||
className="text-xs text-gray-500 hover:text-accent-400 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showTagsHelp ? 'rotate-180' : ''}`} />
|
||||
Справка по тегам
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Tag Buttons */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => insertTag('<b>', '</b>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-bold transition-colors"
|
||||
title="Жирный"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<i>', '</i>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded italic transition-colors"
|
||||
title="Курсив"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<u>', '</u>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded underline transition-colors"
|
||||
title="Подчёркнутый"
|
||||
>
|
||||
U
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<s>', '</s>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded line-through transition-colors"
|
||||
title="Зачёркнутый"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<code>', '</code>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
|
||||
title="Код"
|
||||
>
|
||||
{'</>'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<pre>', '</pre>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
|
||||
title="Блок кода"
|
||||
>
|
||||
PRE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<a href="URL">', '</a>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||
title="Ссылка"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<tg-spoiler>', '</tg-spoiler>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||
title="Спойлер"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertTag('<blockquote>', '</blockquote>')}
|
||||
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||
title="Цитата"
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags Help */}
|
||||
{showTagsHelp && (
|
||||
<div className="p-3 bg-dark-800 border border-dark-600 rounded-lg text-xs space-y-2">
|
||||
<p className="text-gray-400 font-medium mb-2">Поддерживаемые теги Telegram:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{TELEGRAM_TAGS.map((t) => (
|
||||
<div key={t.tag} className="flex items-center gap-2">
|
||||
<code className="text-neon-400 bg-dark-700 px-1.5 py-0.5 rounded">{t.tag}</code>
|
||||
<span className="text-gray-500">— {t.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => handleMessageChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={8}
|
||||
placeholder="Введите текст сообщения..."
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-gray-600">
|
||||
Ctrl+Z — отмена • Ctrl+Shift+Z — повтор
|
||||
</p>
|
||||
<p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||
{message.length} / 2000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<NeonButton
|
||||
size="lg"
|
||||
color="purple"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
isLoading={sending}
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||
</NeonButton>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<NeonButton
|
||||
size="lg"
|
||||
color="purple"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
isLoading={sending}
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
>
|
||||
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||
</NeonButton>
|
||||
{/* Right Column - Preview */}
|
||||
<div className="w-96 flex-shrink-0">
|
||||
<div className="sticky top-6 space-y-3">
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Предпросмотр</span>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||
</p>
|
||||
{/* Telegram-style Preview */}
|
||||
<div className="bg-[#0e1621] rounded-2xl p-4 border border-dark-600 shadow-xl">
|
||||
{/* Chat Header */}
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-dark-600 mb-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center text-white font-bold">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium text-sm">Marathon Bot</p>
|
||||
<p className="text-gray-500 text-xs">бот</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Bubble */}
|
||||
<div className="bg-[#182533] rounded-2xl rounded-tl-md p-3 max-w-full">
|
||||
{message.trim() ? (
|
||||
<div
|
||||
className="text-white text-sm break-words telegram-preview"
|
||||
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
|
||||
)}
|
||||
<p className="text-gray-500 text-xs text-right mt-2">
|
||||
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-4 pt-3 border-t border-dark-600">
|
||||
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
<span>
|
||||
Так будет выглядеть сообщение в Telegram
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Notes */}
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>• Спойлеры (<code className="text-gray-400"><tg-spoiler></code>) показаны размытыми</p>
|
||||
<p>• Цитаты (<code className="text-gray-400"><blockquote></code>) с вертикальной линией</p>
|
||||
<p>• Ссылки будут кликабельны в Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom styles for Telegram preview */}
|
||||
<style>{`
|
||||
.telegram-preview b, .telegram-preview strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.telegram-preview i, .telegram-preview em {
|
||||
font-style: italic;
|
||||
}
|
||||
.telegram-preview u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.telegram-preview s, .telegram-preview strike, .telegram-preview del {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.telegram-preview code {
|
||||
font-family: monospace;
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.telegram-preview pre {
|
||||
font-family: monospace;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 4px 0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.telegram-preview a {
|
||||
color: #6ab2f2;
|
||||
text-decoration: none;
|
||||
}
|
||||
.telegram-preview a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.telegram-preview .tg-spoiler {
|
||||
background: #333;
|
||||
color: transparent;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.telegram-preview .tg-spoiler:hover {
|
||||
color: white;
|
||||
background: transparent;
|
||||
}
|
||||
.telegram-preview blockquote {
|
||||
border-left: 3px solid #6ab2f2;
|
||||
padding-left: 12px;
|
||||
margin: 8px 0;
|
||||
color: #aaa;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user