Add upload images
This commit is contained in:
@@ -92,13 +92,31 @@ export const adminApi = {
|
||||
},
|
||||
|
||||
// Broadcast
|
||||
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||
broadcastToAll: async (message: string, media?: File[]): Promise<BroadcastResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('message', message)
|
||||
if (media && media.length > 0) {
|
||||
media.forEach(file => {
|
||||
formData.append('media', file)
|
||||
})
|
||||
}
|
||||
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||
broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('message', message)
|
||||
if (media && media.length > 0) {
|
||||
media.forEach(file => {
|
||||
formData.append('media', file)
|
||||
})
|
||||
}
|
||||
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ import { adminApi } from '@/api'
|
||||
import type { AdminMarathon } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X } from 'lucide-react'
|
||||
import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X, Image, Film, Trash2, Plus } from 'lucide-react'
|
||||
|
||||
interface MediaItem {
|
||||
file: File
|
||||
preview: string
|
||||
type: 'photo' | 'video'
|
||||
}
|
||||
|
||||
// Telegram supported tags for reference
|
||||
const TELEGRAM_TAGS = [
|
||||
@@ -40,6 +46,8 @@ export function AdminBroadcastPage() {
|
||||
const [marathonSearch, setMarathonSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
|
||||
const [showTagsHelp, setShowTagsHelp] = useState(false)
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Undo/Redo history
|
||||
const [history, setHistory] = useState<string[]>([''])
|
||||
@@ -132,6 +140,72 @@ export function AdminBroadcastPage() {
|
||||
}
|
||||
}, [undo, redo])
|
||||
|
||||
// Handle media file selection (multiple)
|
||||
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
// Check max limit (10 files for Telegram media group)
|
||||
if (mediaItems.length + files.length > 10) {
|
||||
toast.error('Максимум 10 файлов')
|
||||
return
|
||||
}
|
||||
|
||||
const newItems: MediaItem[] = []
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
// Validate file type
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
if (!isImage && !isVideo) {
|
||||
toast.error(`${file.name}: поддерживаются только изображения и видео`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate file size (20MB for images, 50MB for videos)
|
||||
const maxSize = isImage ? 20 * 1024 * 1024 : 50 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
toast.error(`${file.name}: файл слишком большой. Максимум: ${isImage ? '20MB' : '50MB'}`)
|
||||
continue
|
||||
}
|
||||
|
||||
newItems.push({
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
type: isImage ? 'photo' : 'video'
|
||||
})
|
||||
}
|
||||
|
||||
if (newItems.length > 0) {
|
||||
setMediaItems(prev => [...prev, ...newItems])
|
||||
}
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Remove single media item
|
||||
const removeMediaItem = (index: number) => {
|
||||
setMediaItems(prev => {
|
||||
const item = prev[index]
|
||||
if (item) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}
|
||||
|
||||
// Clear all media
|
||||
const clearAllMedia = () => {
|
||||
mediaItems.forEach(item => URL.revokeObjectURL(item.preview))
|
||||
setMediaItems([])
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Filter marathons based on search and status
|
||||
const filteredMarathons = useMemo(() => {
|
||||
return marathons.filter(m => {
|
||||
@@ -145,8 +219,8 @@ export function AdminBroadcastPage() {
|
||||
const selectedMarathon = marathons.find(m => m.id === marathonId)
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!message.trim()) {
|
||||
toast.error('Введите сообщение')
|
||||
if (!message.trim() && mediaItems.length === 0) {
|
||||
toast.error('Введите сообщение или прикрепите медиа')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -157,15 +231,17 @@ export function AdminBroadcastPage() {
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const files = mediaItems.map(item => item.file)
|
||||
let result
|
||||
if (targetType === 'all') {
|
||||
result = await adminApi.broadcastToAll(message)
|
||||
result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined)
|
||||
} else {
|
||||
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||
result = await adminApi.broadcastToMarathon(marathonId!, message, files.length > 0 ? files : undefined)
|
||||
}
|
||||
|
||||
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||
setMessage('')
|
||||
clearAllMedia()
|
||||
} catch (err) {
|
||||
console.error('Failed to send broadcast:', err)
|
||||
toast.error('Ошибка отправки')
|
||||
@@ -469,6 +545,90 @@ export function AdminBroadcastPage() {
|
||||
{message.length} / 2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media Attachment */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleMediaSelect}
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={mediaItems.length >= 10}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Image className="w-4 h-4" />
|
||||
<Film className="w-4 h-4" />
|
||||
<span>Прикрепить медиа</span>
|
||||
</button>
|
||||
{mediaItems.length > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-gray-500">
|
||||
{mediaItems.length}/10 файлов
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAllMedia}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Удалить все
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Media Grid Preview */}
|
||||
{mediaItems.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{mediaItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative aspect-square rounded-lg overflow-hidden border border-dark-600 bg-dark-800 group"
|
||||
>
|
||||
{item.type === 'photo' ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative w-full h-full">
|
||||
<video
|
||||
src={item.preview}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Film className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeMediaItem(index)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500/80 hover:bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/60 rounded text-xs text-white">
|
||||
{item.type === 'photo' ? 'IMG' : 'VID'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mediaItems.length < 10 && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="aspect-square rounded-lg border-2 border-dashed border-dark-500 hover:border-accent-500/50 flex items-center justify-center text-gray-500 hover:text-accent-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
@@ -476,7 +636,7 @@ export function AdminBroadcastPage() {
|
||||
size="lg"
|
||||
color="purple"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
disabled={sending || (!message.trim() && mediaItems.length === 0) || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||
isLoading={sending}
|
||||
icon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
@@ -521,18 +681,54 @@ export function AdminBroadcastPage() {
|
||||
</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>
|
||||
<div className="bg-[#182533] rounded-2xl rounded-tl-md overflow-hidden max-w-full">
|
||||
{/* Media Preview in Telegram style */}
|
||||
{mediaItems.length > 0 && (
|
||||
<div className={`${mediaItems.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''}`}>
|
||||
{mediaItems.slice(0, 4).map((item, index) => (
|
||||
<div key={index} className="relative">
|
||||
{item.type === 'photo' ? (
|
||||
<img
|
||||
src={item.preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative bg-black">
|
||||
<video
|
||||
src={item.preview}
|
||||
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-white/30 backdrop-blur flex items-center justify-center">
|
||||
<Film className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show +N indicator on last visible item if there are more */}
|
||||
{index === 3 && mediaItems.length > 4 && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<span className="text-white text-lg font-bold">+{mediaItems.length - 4}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-500 text-xs text-right mt-2">
|
||||
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
<div className="p-3">
|
||||
{message.trim() ? (
|
||||
<div
|
||||
className="text-white text-sm break-words telegram-preview"
|
||||
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
|
||||
/>
|
||||
) : mediaItems.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
|
||||
) : null}
|
||||
<p className="text-gray-500 text-xs text-right mt-2">
|
||||
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
|
||||
Reference in New Issue
Block a user