Add upload images
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Form
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -531,9 +532,10 @@ async def get_logs(
|
|||||||
@limiter.limit("1/minute")
|
@limiter.limit("1/minute")
|
||||||
async def broadcast_to_all(
|
async def broadcast_to_all(
|
||||||
request: Request,
|
request: Request,
|
||||||
data: BroadcastRequest,
|
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
message: str = Form(""),
|
||||||
|
media: list[UploadFile] = File(default=[]),
|
||||||
):
|
):
|
||||||
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||||
require_admin_with_2fa(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
@@ -547,15 +549,40 @@ async def broadcast_to_all(
|
|||||||
total_count = len(users)
|
total_count = len(users)
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
|
|
||||||
|
# Read media files if provided (up to 10 files, Telegram limit)
|
||||||
|
media_items = []
|
||||||
|
for file in media[:10]:
|
||||||
|
if file and file.filename:
|
||||||
|
file_data = await file.read()
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
media_items.append({
|
||||||
|
"type": "photo",
|
||||||
|
"data": file_data,
|
||||||
|
"filename": file.filename,
|
||||||
|
"content_type": content_type
|
||||||
|
})
|
||||||
|
elif content_type.startswith("video/"):
|
||||||
|
media_items.append({
|
||||||
|
"type": "video",
|
||||||
|
"data": file_data,
|
||||||
|
"filename": file.filename,
|
||||||
|
"content_type": content_type
|
||||||
|
})
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
if await telegram_notifier.send_media_message(
|
||||||
|
user.telegram_id,
|
||||||
|
text=message if message.strip() else None,
|
||||||
|
media_items=media_items if media_items else None
|
||||||
|
):
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
||||||
# Log action
|
# Log action
|
||||||
await log_admin_action(
|
await log_admin_action(
|
||||||
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||||
"broadcast", 0,
|
"broadcast", 0,
|
||||||
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
{"message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
||||||
request.client.host if request.client else None
|
request.client.host if request.client else None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -567,9 +594,10 @@ async def broadcast_to_all(
|
|||||||
async def broadcast_to_marathon(
|
async def broadcast_to_marathon(
|
||||||
request: Request,
|
request: Request,
|
||||||
marathon_id: int,
|
marathon_id: int,
|
||||||
data: BroadcastRequest,
|
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
message: str = Form(""),
|
||||||
|
media: list[UploadFile] = File(default=[]),
|
||||||
):
|
):
|
||||||
"""Send broadcast message to marathon participants. Admin only."""
|
"""Send broadcast message to marathon participants. Admin only."""
|
||||||
require_admin_with_2fa(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
@@ -580,7 +608,7 @@ async def broadcast_to_marathon(
|
|||||||
if not marathon:
|
if not marathon:
|
||||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
# Get participants count
|
# Get participants with telegram
|
||||||
total_result = await db.execute(
|
total_result = await db.execute(
|
||||||
select(User)
|
select(User)
|
||||||
.join(Participant, Participant.user_id == User.id)
|
.join(Participant, Participant.user_id == User.id)
|
||||||
@@ -592,15 +620,41 @@ async def broadcast_to_marathon(
|
|||||||
users = total_result.scalars().all()
|
users = total_result.scalars().all()
|
||||||
total_count = len(users)
|
total_count = len(users)
|
||||||
|
|
||||||
sent_count = await telegram_notifier.notify_marathon_participants(
|
# Read media files if provided (up to 10 files, Telegram limit)
|
||||||
db, marathon_id, data.message
|
media_items = []
|
||||||
)
|
for file in media[:10]:
|
||||||
|
if file and file.filename:
|
||||||
|
file_data = await file.read()
|
||||||
|
content_type = file.content_type or ""
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
media_items.append({
|
||||||
|
"type": "photo",
|
||||||
|
"data": file_data,
|
||||||
|
"filename": file.filename,
|
||||||
|
"content_type": content_type
|
||||||
|
})
|
||||||
|
elif content_type.startswith("video/"):
|
||||||
|
media_items.append({
|
||||||
|
"type": "video",
|
||||||
|
"data": file_data,
|
||||||
|
"filename": file.filename,
|
||||||
|
"content_type": content_type
|
||||||
|
})
|
||||||
|
|
||||||
|
sent_count = 0
|
||||||
|
for user in users:
|
||||||
|
if await telegram_notifier.send_media_message(
|
||||||
|
user.telegram_id,
|
||||||
|
text=message if message.strip() else None,
|
||||||
|
media_items=media_items if media_items else None
|
||||||
|
):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
# Log action
|
# Log action
|
||||||
await log_admin_action(
|
await log_admin_action(
|
||||||
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||||
"marathon", marathon_id,
|
"marathon", marathon_id,
|
||||||
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
{"title": marathon.title, "message": message[:100], "sent": sent_count, "total": total_count, "media_count": len(media_items)},
|
||||||
request.client.host if request.client else None
|
request.client.host if request.client else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,209 @@ class TelegramNotifier:
|
|||||||
logger.error(f"Error sending Telegram message: {e}")
|
logger.error(f"Error sending Telegram message: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def send_photo(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
photo: bytes,
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
filename: str = "photo.jpg",
|
||||||
|
content_type: str = "image/jpeg"
|
||||||
|
) -> bool:
|
||||||
|
"""Send a photo to a Telegram chat."""
|
||||||
|
if not self.bot_token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
data = {"chat_id": str(chat_id)}
|
||||||
|
if caption:
|
||||||
|
data["caption"] = caption
|
||||||
|
data["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
files = {"photo": (filename, photo, content_type)}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/sendPhoto",
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_video(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
video: bytes,
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
filename: str = "video.mp4",
|
||||||
|
content_type: str = "video/mp4"
|
||||||
|
) -> bool:
|
||||||
|
"""Send a video to a Telegram chat."""
|
||||||
|
if not self.bot_token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
data = {"chat_id": str(chat_id)}
|
||||||
|
if caption:
|
||||||
|
data["caption"] = caption
|
||||||
|
data["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
files = {"video": (filename, video, content_type)}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/sendVideo",
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_media_group(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
media_items: list[dict],
|
||||||
|
caption: str | None = None,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a media group (multiple photos/videos) to a Telegram chat.
|
||||||
|
|
||||||
|
media_items: list of dicts with keys:
|
||||||
|
- type: "photo" or "video"
|
||||||
|
- data: bytes
|
||||||
|
- filename: str
|
||||||
|
- content_type: str
|
||||||
|
"""
|
||||||
|
if not self.bot_token:
|
||||||
|
logger.warning("Telegram bot token not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not media_items:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
# Use longer timeouts for file uploads
|
||||||
|
timeout = httpx.Timeout(
|
||||||
|
connect=30.0,
|
||||||
|
read=120.0,
|
||||||
|
write=300.0, # 5 minutes for uploading files
|
||||||
|
pool=30.0
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
# Build media array and files dict
|
||||||
|
media_array = []
|
||||||
|
files_dict = {}
|
||||||
|
|
||||||
|
for i, item in enumerate(media_items):
|
||||||
|
attach_name = f"media{i}"
|
||||||
|
media_obj = {
|
||||||
|
"type": item["type"],
|
||||||
|
"media": f"attach://{attach_name}"
|
||||||
|
}
|
||||||
|
# Only first item gets the caption
|
||||||
|
if i == 0 and caption:
|
||||||
|
media_obj["caption"] = caption
|
||||||
|
media_obj["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
media_array.append(media_obj)
|
||||||
|
files_dict[attach_name] = (
|
||||||
|
item.get("filename", f"file{i}"),
|
||||||
|
item["data"],
|
||||||
|
item.get("content_type", "application/octet-stream")
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"chat_id": str(chat_id),
|
||||||
|
"media": json.dumps(media_array)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Sending media group to {chat_id}: {len(media_items)} files")
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_url}/sendMediaGroup",
|
||||||
|
data=data,
|
||||||
|
files=files_dict,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info(f"Successfully sent media group to {chat_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_media_message(
|
||||||
|
self,
|
||||||
|
chat_id: int,
|
||||||
|
text: str | None = None,
|
||||||
|
media_type: str | None = None,
|
||||||
|
media_data: bytes | None = None,
|
||||||
|
media_items: list[dict] | None = None,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a message with optional media.
|
||||||
|
|
||||||
|
For single media: use media_type and media_data
|
||||||
|
For multiple media: use media_items list with dicts containing:
|
||||||
|
- type: "photo" or "video"
|
||||||
|
- data: bytes
|
||||||
|
- filename: str (optional)
|
||||||
|
- content_type: str (optional)
|
||||||
|
"""
|
||||||
|
# Multiple media - use media group
|
||||||
|
if media_items and len(media_items) > 1:
|
||||||
|
return await self.send_media_group(chat_id, media_items, text, parse_mode)
|
||||||
|
|
||||||
|
# Single media from media_items
|
||||||
|
if media_items and len(media_items) == 1:
|
||||||
|
item = media_items[0]
|
||||||
|
if item["type"] == "photo":
|
||||||
|
return await self.send_photo(
|
||||||
|
chat_id, item["data"], text, parse_mode,
|
||||||
|
item.get("filename", "photo.jpg"),
|
||||||
|
item.get("content_type", "image/jpeg")
|
||||||
|
)
|
||||||
|
elif item["type"] == "video":
|
||||||
|
return await self.send_video(
|
||||||
|
chat_id, item["data"], text, parse_mode,
|
||||||
|
item.get("filename", "video.mp4"),
|
||||||
|
item.get("content_type", "video/mp4")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy single media support
|
||||||
|
if media_data and media_type:
|
||||||
|
if media_type == "photo":
|
||||||
|
return await self.send_photo(chat_id, media_data, text, parse_mode)
|
||||||
|
elif media_type == "video":
|
||||||
|
return await self.send_video(chat_id, media_data, text, parse_mode)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
return await self.send_message(chat_id, text, parse_mode)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
async def notify_user(
|
async def notify_user(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -92,13 +92,31 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Broadcast
|
// Broadcast
|
||||||
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
broadcastToAll: async (message: string, media?: File[]): Promise<BroadcastResponse> => {
|
||||||
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
|
||||||
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ 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, 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
|
// Telegram supported tags for reference
|
||||||
const TELEGRAM_TAGS = [
|
const TELEGRAM_TAGS = [
|
||||||
@@ -40,6 +46,8 @@ export function AdminBroadcastPage() {
|
|||||||
const [marathonSearch, setMarathonSearch] = useState('')
|
const [marathonSearch, setMarathonSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
|
||||||
const [showTagsHelp, setShowTagsHelp] = useState(false)
|
const [showTagsHelp, setShowTagsHelp] = useState(false)
|
||||||
|
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Undo/Redo history
|
// Undo/Redo history
|
||||||
const [history, setHistory] = useState<string[]>([''])
|
const [history, setHistory] = useState<string[]>([''])
|
||||||
@@ -132,6 +140,72 @@ export function AdminBroadcastPage() {
|
|||||||
}
|
}
|
||||||
}, [undo, redo])
|
}, [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
|
// Filter marathons based on search and status
|
||||||
const filteredMarathons = useMemo(() => {
|
const filteredMarathons = useMemo(() => {
|
||||||
return marathons.filter(m => {
|
return marathons.filter(m => {
|
||||||
@@ -145,8 +219,8 @@ export function AdminBroadcastPage() {
|
|||||||
const selectedMarathon = marathons.find(m => m.id === marathonId)
|
const selectedMarathon = marathons.find(m => m.id === marathonId)
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!message.trim()) {
|
if (!message.trim() && mediaItems.length === 0) {
|
||||||
toast.error('Введите сообщение')
|
toast.error('Введите сообщение или прикрепите медиа')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,15 +231,17 @@ export function AdminBroadcastPage() {
|
|||||||
|
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
|
const files = mediaItems.map(item => item.file)
|
||||||
let result
|
let result
|
||||||
if (targetType === 'all') {
|
if (targetType === 'all') {
|
||||||
result = await adminApi.broadcastToAll(message)
|
result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined)
|
||||||
} else {
|
} 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} сообщений`)
|
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
clearAllMedia()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send broadcast:', err)
|
console.error('Failed to send broadcast:', err)
|
||||||
toast.error('Ошибка отправки')
|
toast.error('Ошибка отправки')
|
||||||
@@ -469,6 +545,90 @@ export function AdminBroadcastPage() {
|
|||||||
{message.length} / 2000
|
{message.length} / 2000
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Send Button */}
|
{/* Send Button */}
|
||||||
@@ -476,7 +636,7 @@ export function AdminBroadcastPage() {
|
|||||||
size="lg"
|
size="lg"
|
||||||
color="purple"
|
color="purple"
|
||||||
onClick={handleSend}
|
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}
|
isLoading={sending}
|
||||||
icon={<Send className="w-5 h-5" />}
|
icon={<Send className="w-5 h-5" />}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -521,19 +681,55 @@ export function AdminBroadcastPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message Bubble */}
|
{/* Message Bubble */}
|
||||||
<div className="bg-[#182533] rounded-2xl rounded-tl-md p-3 max-w-full">
|
<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>
|
||||||
|
)}
|
||||||
|
<div className="p-3">
|
||||||
{message.trim() ? (
|
{message.trim() ? (
|
||||||
<div
|
<div
|
||||||
className="text-white text-sm break-words telegram-preview"
|
className="text-white text-sm break-words telegram-preview"
|
||||||
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
|
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : mediaItems.length === 0 ? (
|
||||||
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
|
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
|
||||||
)}
|
) : null}
|
||||||
<p className="text-gray-500 text-xs text-right mt-2">
|
<p className="text-gray-500 text-xs text-right mt-2">
|
||||||
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="mt-4 pt-3 border-t border-dark-600">
|
<div className="mt-4 pt-3 border-t border-dark-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user