Add upload images

This commit is contained in:
2026-01-04 04:58:41 +07:00
parent 81d992abe6
commit 65b2512d8c
4 changed files with 502 additions and 31 deletions

View File

@@ -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
},

View File

@@ -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 */}