remake send push systems

This commit is contained in:
2026-01-04 04:16:54 +07:00
parent 9014d5d79d
commit 81d992abe6
2 changed files with 532 additions and 105 deletions

View File

@@ -210,7 +210,7 @@ async def list_marathons(
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=200),
search: str | None = None, search: str | None = None,
): ):
"""List all marathons. Admin only.""" """List all marathons. Admin only."""

View File

@@ -1,9 +1,34 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { adminApi } from '@/api' import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types' import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui' 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() { export function AdminBroadcastPage() {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -12,11 +37,26 @@ export function AdminBroadcastPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([]) const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [loadingMarathons, setLoadingMarathons] = 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() const toast = useToast()
// Load marathons on mount and when switching to marathon target
useEffect(() => { 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() loadMarathons()
} }
}, [targetType]) }, [targetType])
@@ -24,15 +64,86 @@ export function AdminBroadcastPage() {
const loadMarathons = async () => { const loadMarathons = async () => {
setLoadingMarathons(true) setLoadingMarathons(true)
try { try {
const data = await adminApi.listMarathons(0, 100) const data = await adminApi.listMarathons(0, 200)
setMarathons(data.filter(m => m.status === 'active')) console.log('Loaded marathons:', data)
setMarathons(data)
} catch (err) { } catch (err) {
console.error('Failed to load marathons:', err) console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally { } finally {
setLoadingMarathons(false) 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 () => { const handleSend = async () => {
if (!message.trim()) { if (!message.trim()) {
toast.error('Введите сообщение') 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -73,7 +217,9 @@ export function AdminBroadcastPage() {
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1> <h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
</div> </div>
<div className="max-w-2xl space-y-6"> <div className="flex gap-6">
{/* Left Column - Editor */}
<div className="flex-1 space-y-6">
{/* Target Selection */} {/* Target Selection */}
<div className="space-y-3"> <div className="space-y-3">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
@@ -110,49 +256,216 @@ export function AdminBroadcastPage() {
{/* Marathon Selection */} {/* Marathon Selection */}
{targetType === 'marathon' && ( {targetType === 'marathon' && (
<div className="space-y-2"> <div className="space-y-3">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Выберите марафон Выберите марафон
</label> </label>
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" /> {/* 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 <select
value={marathonId || ''} value={statusFilter}
onChange={(e) => setMarathonId(Number(e.target.value) || null)} onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
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" 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="">Выберите марафон...</option> <option value="all">Все статусы</option>
{marathons.map((m) => ( <option value="active">Активные</option>
<option key={m.id} value={m.id}> <option value="preparing">Подготовка</option>
{m.title} ({m.participants_count} участников) <option value="finished">Завершённые</option>
</option>
))}
</select> </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>
) : (
'Марафоны не найдены по фильтру'
)} )}
{marathons.length === 0 && !loadingMarathons && ( </div>
<p className="text-sm text-gray-500">Нет активных марафонов</p> ) : (
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> </div>
)} )}
{/* Message */} {/* 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="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300"> <label className="block text-sm font-medium text-gray-300">
Сообщение Сообщение
</label> </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 <textarea
ref={textareaRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => handleMessageChange(e.target.value)}
rows={6} onKeyDown={handleKeyDown}
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)" rows={8}
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" 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 text-xs"> <div className="flex items-center justify-between">
<p className="text-gray-500"> <p className="text-xs text-gray-600">
Поддерживается HTML: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt; Ctrl+Z отмена Ctrl+Shift+Z повтор
</p> </p>
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}> <p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000 {message.length} / 2000
</p> </p>
</div> </div>
@@ -185,6 +498,120 @@ export function AdminBroadcastPage() {
</div> </div>
</div> </div>
</div> </div>
{/* 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>
{/* 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">&lt;tg-spoiler&gt;</code>) показаны размытыми</p>
<p> Цитаты (<code className="text-gray-400">&lt;blockquote&gt;</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> </div>
) )
} }