Add admin panel
This commit is contained in:
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect } 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'
|
||||
|
||||
export function AdminBroadcastPage() {
|
||||
const [message, setMessage] = useState('')
|
||||
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
||||
const [marathonId, setMarathonId] = useState<number | null>(null)
|
||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
if (targetType === 'marathon') {
|
||||
loadMarathons()
|
||||
}
|
||||
}, [targetType])
|
||||
|
||||
const loadMarathons = async () => {
|
||||
setLoadingMarathons(true)
|
||||
try {
|
||||
const data = await adminApi.listMarathons(0, 100)
|
||||
setMarathons(data.filter(m => m.status === 'active'))
|
||||
} catch (err) {
|
||||
console.error('Failed to load marathons:', err)
|
||||
} finally {
|
||||
setLoadingMarathons(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) {
|
||||
toast.error('Введите сообщение')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetType === 'marathon' && !marathonId) {
|
||||
toast.error('Выберите марафон')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
let result
|
||||
if (targetType === 'all') {
|
||||
result = await adminApi.broadcastToAll(message)
|
||||
} else {
|
||||
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||
}
|
||||
|
||||
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||
setMessage('')
|
||||
} catch (err) {
|
||||
console.error('Failed to send broadcast:', err)
|
||||
toast.error('Ошибка отправки')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
||||
<Send className="w-6 h-6 text-pink-400" />
|
||||
</div>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user