Add events
This commit is contained in:
110
frontend/src/components/EventBanner.tsx
Normal file
110
frontend/src/components/EventBanner.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Clock } from 'lucide-react'
|
||||
import type { ActiveEvent, EventType } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventBannerProps {
|
||||
activeEvent: ActiveEvent
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-5 h-5" />,
|
||||
common_enemy: <Users className="w-5 h-5" />,
|
||||
double_risk: <Shield className="w-5 h-5" />,
|
||||
jackpot: <Gift className="w-5 h-5" />,
|
||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||
rematch: <RotateCcw className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, string> = {
|
||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
||||
rematch: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
||||
const [timeRemaining, setTimeRemaining] = useState(activeEvent.time_remaining_seconds)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeRemaining(activeEvent.time_remaining_seconds)
|
||||
}, [activeEvent.time_remaining_seconds])
|
||||
|
||||
useEffect(() => {
|
||||
if (timeRemaining === null || timeRemaining <= 0) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev === null || prev <= 0) {
|
||||
clearInterval(timer)
|
||||
onRefresh?.()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [timeRemaining, onRefresh])
|
||||
|
||||
if (!activeEvent.event) {
|
||||
return null
|
||||
}
|
||||
|
||||
const event = activeEvent.event
|
||||
const info = EVENT_INFO[event.type]
|
||||
const icon = EVENT_ICONS[event.type]
|
||||
const colorClass = EVENT_COLORS[event.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border p-4
|
||||
bg-gradient-to-r ${colorClass}
|
||||
animate-pulse-slow
|
||||
`}
|
||||
>
|
||||
{/* Animated background effect */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
||||
|
||||
<div className="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-white/10">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
||||
<p className="text-sm opacity-80">{info.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeRemaining !== null && timeRemaining > 0 && (
|
||||
<div className="flex items-center gap-2 text-lg font-mono font-bold">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatTime(timeRemaining)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
|
||||
x{activeEvent.effects.points_multiplier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
frontend/src/components/EventControl.tsx
Normal file
161
frontend/src/components/EventControl.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, Play, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
|
||||
interface EventControlProps {
|
||||
marathonId: number
|
||||
activeEvent: ActiveEvent
|
||||
challenges?: Challenge[]
|
||||
onEventChange: () => void
|
||||
}
|
||||
|
||||
const EVENT_TYPES: EventType[] = [
|
||||
'golden_hour',
|
||||
'double_risk',
|
||||
'jackpot',
|
||||
'swap',
|
||||
'rematch',
|
||||
'common_enemy',
|
||||
]
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-4 h-4" />,
|
||||
common_enemy: <Users className="w-4 h-4" />,
|
||||
double_risk: <Shield className="w-4 h-4" />,
|
||||
jackpot: <Gift className="w-4 h-4" />,
|
||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||
rematch: <RotateCcw className="w-4 h-4" />,
|
||||
}
|
||||
|
||||
export function EventControl({
|
||||
marathonId,
|
||||
activeEvent,
|
||||
challenges,
|
||||
onEventChange,
|
||||
}: EventControlProps) {
|
||||
const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
|
||||
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedType === 'common_enemy' && !selectedChallengeId) {
|
||||
alert('Выберите челлендж для события "Общий враг"')
|
||||
return
|
||||
}
|
||||
|
||||
setIsStarting(true)
|
||||
try {
|
||||
await eventsApi.start(marathonId, {
|
||||
type: selectedType,
|
||||
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
|
||||
})
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to start event:', error)
|
||||
alert('Не удалось запустить событие')
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!confirm('Остановить событие досрочно?')) return
|
||||
|
||||
setIsStopping(true)
|
||||
try {
|
||||
await eventsApi.stop(marathonId)
|
||||
onEventChange()
|
||||
} catch (error) {
|
||||
console.error('Failed to stop event:', error)
|
||||
} finally {
|
||||
setIsStopping(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (activeEvent.event) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
<span className="font-medium">
|
||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
isLoading={isStopping}
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Остановить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all text-left
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{EVENT_ICONS[type]}
|
||||
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Выберите челлендж для всех
|
||||
</label>
|
||||
<select
|
||||
value={selectedChallengeId || ''}
|
||||
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
||||
>
|
||||
<option value="">— Выберите челлендж —</option>
|
||||
{challenges.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.game.title}: {c.title} ({c.points} очков)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||
className="w-full"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить {EVENT_INFO[selectedType].name}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user