diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index ce8591f..97a096f 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -210,7 +210,7 @@ async def list_marathons( current_user: CurrentUser, db: DbSession, 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, ): """List all marathons. Admin only.""" diff --git a/frontend/src/pages/admin/AdminBroadcastPage.tsx b/frontend/src/pages/admin/AdminBroadcastPage.tsx index 7198e43..4e323ce 100644 --- a/frontend/src/pages/admin/AdminBroadcastPage.tsx +++ b/frontend/src/pages/admin/AdminBroadcastPage.tsx @@ -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: '', description: 'Жирный текст', example: 'жирный' }, + { tag: '', description: 'Курсив', example: 'курсив' }, + { tag: '', description: 'Подчёркнутый', example: 'подчёркнутый' }, + { tag: '', description: 'Зачёркнутый', example: 'зачёркнутый' }, + { tag: '', description: 'Моноширинный', example: 'код' }, + { tag: '
', description: 'Блок кода', example: '
блок кода
' }, + { tag: '', description: 'Ссылка', example: 'текст' }, + { tag: '', description: 'Спойлер', example: 'скрытый текст' }, + { tag: '
', description: 'Цитата', example: '
цитата
' }, +] + +// 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(//g, '') + .replace(/<\/tg-spoiler>/g, '') + // Convert newlines to
tags (but not inside
 blocks)
+    .replace(/\n/g, '
') + + return html +} export function AdminBroadcastPage() { const [message, setMessage] = useState('') @@ -12,11 +37,26 @@ export function AdminBroadcastPage() { const [marathons, setMarathons] = useState([]) 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(['']) + const [historyIndex, setHistoryIndex] = useState(0) + const isUndoRedo = useRef(false) + const textareaRef = useRef(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) => { + 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 ( + + {c.label} + + ) + } + + 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 (
{/* Header */} @@ -73,118 +217,401 @@ export function AdminBroadcastPage() {

Рассылка уведомлений

-
- {/* Target Selection */} -
- -
- - -
-
- - {/* Marathon Selection */} - {targetType === 'marathon' && ( -
+
+ {/* Left Column - Editor */} +
+ {/* Target Selection */} +
- {loadingMarathons ? ( -
- ) : ( - - )} - {marathons.length === 0 && !loadingMarathons && ( -

Нет активных марафонов

- )} + + Всем пользователям + + +
- )} - {/* Message */} -
- -