Add events history
This commit is contained in:
@@ -11,7 +11,7 @@ from app.core.config import settings
|
||||
from app.models import (
|
||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||
EventType, Difficulty
|
||||
EventType, Difficulty, User
|
||||
)
|
||||
from app.schemas import (
|
||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||
@@ -130,6 +130,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
||||
activity_data = {
|
||||
"game": game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"points": challenge.points,
|
||||
}
|
||||
if active_event:
|
||||
activity_data["event_type"] = active_event.type
|
||||
@@ -328,6 +330,7 @@ async def complete_assignment(
|
||||
db, active_event, participant.id, current_user.id
|
||||
)
|
||||
total_points += common_enemy_bonus
|
||||
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||
|
||||
# Update assignment
|
||||
assignment.status = AssignmentStatus.COMPLETED.value
|
||||
@@ -342,7 +345,9 @@ async def complete_assignment(
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": full_challenge.game.title,
|
||||
"challenge": challenge.title,
|
||||
"difficulty": challenge.difficulty,
|
||||
"points": total_points,
|
||||
"streak": participant.current_streak,
|
||||
}
|
||||
@@ -367,6 +372,24 @@ async def complete_assignment(
|
||||
# If common enemy event auto-closed, log the event end with winners
|
||||
if common_enemy_closed and common_enemy_winners:
|
||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||
# Load winner nicknames
|
||||
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_(winner_user_ids))
|
||||
)
|
||||
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
|
||||
|
||||
winners_data = [
|
||||
{
|
||||
"user_id": w["user_id"],
|
||||
"nickname": users_map.get(w["user_id"], "Unknown"),
|
||||
"rank": w["rank"],
|
||||
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||
}
|
||||
for w in common_enemy_winners
|
||||
]
|
||||
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
|
||||
|
||||
event_end_activity = Activity(
|
||||
marathon_id=marathon_id,
|
||||
user_id=current_user.id, # Last completer triggers the close
|
||||
@@ -375,14 +398,7 @@ async def complete_assignment(
|
||||
"event_type": EventType.COMMON_ENEMY.value,
|
||||
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
||||
"auto_closed": True,
|
||||
"winners": [
|
||||
{
|
||||
"user_id": w["user_id"],
|
||||
"rank": w["rank"],
|
||||
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
||||
}
|
||||
for w in common_enemy_winners
|
||||
],
|
||||
"winners": winners_data,
|
||||
},
|
||||
)
|
||||
db.add(event_end_activity)
|
||||
@@ -440,7 +456,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
||||
|
||||
# Log activity
|
||||
activity_data = {
|
||||
"game": assignment.challenge.game.title,
|
||||
"challenge": assignment.challenge.title,
|
||||
"difficulty": assignment.challenge.difficulty,
|
||||
"penalty": penalty,
|
||||
}
|
||||
if active_event:
|
||||
|
||||
@@ -157,13 +157,16 @@ class EventService:
|
||||
- winners_list: list of winners if event closed, None otherwise
|
||||
"""
|
||||
if event.type != EventType.COMMON_ENEMY.value:
|
||||
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
|
||||
return 0, False, None
|
||||
|
||||
data = event.data or {}
|
||||
completions = data.get("completions", [])
|
||||
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
|
||||
|
||||
# Check if already completed
|
||||
if any(c["participant_id"] == participant_id for c in completions):
|
||||
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
|
||||
return 0, False, None
|
||||
|
||||
# Add completion
|
||||
@@ -174,6 +177,7 @@ class EventService:
|
||||
"completed_at": datetime.utcnow().isoformat(),
|
||||
"rank": rank,
|
||||
})
|
||||
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
|
||||
|
||||
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
||||
event.data = {**data, "completions": completions}
|
||||
@@ -189,6 +193,7 @@ class EventService:
|
||||
event.end_time = datetime.utcnow()
|
||||
event_closed = True
|
||||
winners_list = completions[:3] # Top 3
|
||||
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
247
frontend/src/components/ActivityFeed.tsx
Normal file
247
frontend/src/components/ActivityFeed.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { feedApi } from '@/api'
|
||||
import type { Activity, ActivityType } from '@/types'
|
||||
import { Loader2, ChevronDown, Bell } from 'lucide-react'
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getActivityIcon,
|
||||
getActivityColor,
|
||||
getActivityBgClass,
|
||||
isEventActivity,
|
||||
formatActivityMessage,
|
||||
} from '@/utils/activity'
|
||||
|
||||
interface ActivityFeedProps {
|
||||
marathonId: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface ActivityFeedRef {
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 20
|
||||
const POLL_INTERVAL = 10000 // 10 seconds
|
||||
|
||||
// Важные типы активности для отображения
|
||||
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
|
||||
'spin',
|
||||
'complete',
|
||||
'drop',
|
||||
'start_marathon',
|
||||
'finish_marathon',
|
||||
'event_start',
|
||||
'event_end',
|
||||
'swap',
|
||||
'rematch',
|
||||
]
|
||||
|
||||
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
|
||||
({ marathonId, className = '' }, ref) => {
|
||||
const [activities, setActivities] = useState<Activity[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [total, setTotal] = useState(0)
|
||||
const lastFetchRef = useRef<number>(0)
|
||||
|
||||
const loadActivities = useCallback(async (offset = 0, append = false) => {
|
||||
try {
|
||||
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
|
||||
|
||||
// Фильтруем только важные события
|
||||
const filtered = response.items.filter(item =>
|
||||
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
|
||||
)
|
||||
|
||||
if (append) {
|
||||
setActivities(prev => [...prev, ...filtered])
|
||||
} else {
|
||||
setActivities(filtered)
|
||||
}
|
||||
|
||||
setHasMore(response.has_more)
|
||||
setTotal(filtered.length)
|
||||
lastFetchRef.current = Date.now()
|
||||
} catch (error) {
|
||||
console.error('Failed to load activity feed:', error)
|
||||
}
|
||||
}, [marathonId])
|
||||
|
||||
// Expose refresh method
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => loadActivities()
|
||||
}), [loadActivities])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
loadActivities().finally(() => setIsLoading(false))
|
||||
}, [loadActivities])
|
||||
|
||||
// Polling for new activities
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadActivities()
|
||||
}
|
||||
}, POLL_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [loadActivities])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
setIsLoadingMore(true)
|
||||
await loadActivities(activities.length, true)
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-gray-500">{total}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{activities.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500 text-sm">
|
||||
Пока нет активности
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-700/30">
|
||||
{activities.map((activity) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
<div className="p-3 border-t border-gray-700/30">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={isLoadingMore}
|
||||
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
Загрузить ещё
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ActivityFeed.displayName = 'ActivityFeed'
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity
|
||||
}
|
||||
|
||||
function ActivityItem({ activity }: ActivityItemProps) {
|
||||
const Icon = getActivityIcon(activity.type)
|
||||
const iconColor = getActivityColor(activity.type)
|
||||
const bgClass = getActivityBgClass(activity.type)
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
const { title, details, extra } = formatActivityMessage(activity)
|
||||
|
||||
if (isEvent) {
|
||||
return (
|
||||
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className={`w-4 h-4 ${iconColor}`} />
|
||||
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{details && (
|
||||
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
{activity.user.avatar_url ? (
|
||||
<img
|
||||
src={activity.user.avatar_url}
|
||||
alt={activity.user.nickname}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-xs text-gray-400 font-medium">
|
||||
{activity.user.nickname.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{activity.user.nickname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
||||
<span className="text-sm text-gray-300">{title}</span>
|
||||
</div>
|
||||
{details && (
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{extra && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,30 @@ body {
|
||||
@apply bg-gray-900 text-gray-100 min-h-screen;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4b5563 transparent;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
@@ -6,6 +6,7 @@ import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
@@ -21,6 +22,7 @@ export function MarathonPage() {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [showEventControl, setShowEventControl] = useState(false)
|
||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
@@ -60,6 +62,8 @@ export function MarathonPage() {
|
||||
try {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
// Refresh activity feed when event changes
|
||||
activityFeedRef.current?.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh event:', error)
|
||||
}
|
||||
@@ -122,243 +126,261 @@ export function MarathonPage() {
|
||||
const canDelete = isCreator || user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
К списку марафонов
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||
marathon.is_public
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}>
|
||||
{marathon.is_public ? (
|
||||
<><Globe className="w-3 h-3" /> Открытый</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||
marathon.is_public
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}>
|
||||
{marathon.is_public ? (
|
||||
<><Globe className="w-3 h-3" /> Открытый</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400">{marathon.description}</p>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{/* Кнопка присоединиться для открытых марафонов */}
|
||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Присоединиться
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Настройка для организаторов */}
|
||||
{marathon.status === 'preparing' && isOrganizer && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Настройка
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
||||
Предложить игру
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{marathon.status === 'active' && isParticipant && (
|
||||
<Link to={`/marathons/${id}/play`}>
|
||||
<Button>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Играть
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to={`/marathons/${id}/leaderboard`}>
|
||||
<Button variant="secondary">
|
||||
<Trophy className="w-4 h-4 mr-2" />
|
||||
Рейтинг
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
isLoading={isDeleting}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400">{marathon.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Кнопка присоединиться для открытых марафонов */}
|
||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Присоединиться
|
||||
</Button>
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
Участников
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
||||
<div className="text-sm text-gray-400">Игр</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Начало
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<CalendarCheck className="w-4 h-4" />
|
||||
Конец
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className={`text-2xl font-bold ${
|
||||
marathon.status === 'active' ? 'text-green-500' :
|
||||
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Статус</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active event banner */}
|
||||
{marathon.status === 'active' && activeEvent?.event && (
|
||||
<div className="mb-8">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Настройка для организаторов */}
|
||||
{marathon.status === 'preparing' && isOrganizer && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Настройка
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
||||
Предложить игру
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{marathon.status === 'active' && isParticipant && (
|
||||
<Link to={`/marathons/${id}/play`}>
|
||||
<Button>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Играть
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to={`/marathons/${id}/leaderboard`}>
|
||||
<Button variant="secondary">
|
||||
<Trophy className="w-4 h-4 mr-2" />
|
||||
Рейтинг
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
isLoading={isDeleting}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-5 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
Участников
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
||||
<div className="text-sm text-gray-400">Игр</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Начало
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<CalendarCheck className="w-4 h-4" />
|
||||
Конец
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className={`text-2xl font-bold ${
|
||||
marathon.status === 'active' ? 'text-green-500' :
|
||||
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Статус</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Active event banner */}
|
||||
{marathon.status === 'active' && activeEvent?.event && (
|
||||
<div className="mb-8">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event control for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Управление событиями
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEventControl(!showEventControl)}
|
||||
>
|
||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||
</Button>
|
||||
</div>
|
||||
{showEventControl && activeEvent && (
|
||||
<EventControl
|
||||
marathonId={marathon.id}
|
||||
activeEvent={activeEvent}
|
||||
challenges={challenges}
|
||||
onEventChange={refreshEvent}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
||||
{getInviteLink()}
|
||||
</code>
|
||||
<Button variant="secondary" onClick={copyInviteLink}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Скопировано!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Копировать
|
||||
</>
|
||||
{/* Event control for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Управление событиями
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEventControl(!showEventControl)}
|
||||
>
|
||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||
</Button>
|
||||
</div>
|
||||
{showEventControl && activeEvent && (
|
||||
<EventControl
|
||||
marathonId={marathon.id}
|
||||
activeEvent={activeEvent}
|
||||
challenges={challenges}
|
||||
onEventChange={refreshEvent}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* My stats */}
|
||||
{marathon.my_participation && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-500">
|
||||
{marathon.my_participation.total_points}
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
||||
{getInviteLink()}
|
||||
</code>
|
||||
<Button variant="secondary" onClick={copyInviteLink}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Скопировано!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Копировать
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Очков</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-500">
|
||||
{marathon.my_participation.current_streak}
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* My stats */}
|
||||
{marathon.my_participation && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-500">
|
||||
{marathon.my_participation.total_points}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Очков</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-500">
|
||||
{marathon.my_participation.current_streak}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Серия</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-400">
|
||||
{marathon.my_participation.drop_count}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Пропусков</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Серия</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-400">
|
||||
{marathon.my_participation.drop_count}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Пропусков</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity Feed - right sidebar */}
|
||||
{isParticipant && (
|
||||
<div className="lg:w-96 flex-shrink-0">
|
||||
<div className="lg:sticky lg:top-4">
|
||||
<ActivityFeed
|
||||
ref={activityFeedRef}
|
||||
marathonId={marathon.id}
|
||||
className="lg:max-h-[calc(100vh-8rem)]"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ export type ActivityType =
|
||||
| 'add_game'
|
||||
| 'approve_game'
|
||||
| 'reject_game'
|
||||
| 'event_start'
|
||||
| 'event_end'
|
||||
| 'swap'
|
||||
| 'rematch'
|
||||
|
||||
export interface Activity {
|
||||
id: number
|
||||
|
||||
250
frontend/src/utils/activity.ts
Normal file
250
frontend/src/utils/activity.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Activity, ActivityType, EventType } from '@/types'
|
||||
import {
|
||||
UserPlus,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Play,
|
||||
Flag,
|
||||
Plus,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Zap,
|
||||
ZapOff,
|
||||
ArrowLeftRight,
|
||||
RefreshCw,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Relative time formatting
|
||||
export function formatRelativeTime(dateStr: string): string {
|
||||
// Backend saves time in UTC, ensure we parse it correctly
|
||||
// If the string doesn't end with 'Z', append it to indicate UTC
|
||||
const utcDateStr = dateStr.endsWith('Z') ? dateStr : dateStr + 'Z'
|
||||
const date = new Date(utcDateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffSec < 60) return 'только что'
|
||||
if (diffMin < 60) {
|
||||
if (diffMin === 1) return '1 мин назад'
|
||||
if (diffMin < 5) return `${diffMin} мин назад`
|
||||
if (diffMin < 21 && diffMin > 4) return `${diffMin} мин назад`
|
||||
return `${diffMin} мин назад`
|
||||
}
|
||||
if (diffHour < 24) {
|
||||
if (diffHour === 1) return '1 час назад'
|
||||
if (diffHour < 5) return `${diffHour} часа назад`
|
||||
return `${diffHour} часов назад`
|
||||
}
|
||||
if (diffDay === 1) return 'вчера'
|
||||
if (diffDay < 7) return `${diffDay} дн назад`
|
||||
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Activity icon mapping
|
||||
export function getActivityIcon(type: ActivityType): LucideIcon {
|
||||
const icons: Record<ActivityType, LucideIcon> = {
|
||||
join: UserPlus,
|
||||
spin: RotateCcw,
|
||||
complete: CheckCircle,
|
||||
drop: XCircle,
|
||||
start_marathon: Play,
|
||||
finish_marathon: Flag,
|
||||
add_game: Plus,
|
||||
approve_game: ThumbsUp,
|
||||
reject_game: ThumbsDown,
|
||||
event_start: Zap,
|
||||
event_end: ZapOff,
|
||||
swap: ArrowLeftRight,
|
||||
rematch: RefreshCw,
|
||||
}
|
||||
return icons[type] || Zap
|
||||
}
|
||||
|
||||
// Activity color mapping
|
||||
export function getActivityColor(type: ActivityType): string {
|
||||
const colors: Record<ActivityType, string> = {
|
||||
join: 'text-green-400',
|
||||
spin: 'text-blue-400',
|
||||
complete: 'text-green-400',
|
||||
drop: 'text-red-400',
|
||||
start_marathon: 'text-green-400',
|
||||
finish_marathon: 'text-gray-400',
|
||||
add_game: 'text-blue-400',
|
||||
approve_game: 'text-green-400',
|
||||
reject_game: 'text-red-400',
|
||||
event_start: 'text-yellow-400',
|
||||
event_end: 'text-gray-400',
|
||||
swap: 'text-purple-400',
|
||||
rematch: 'text-orange-400',
|
||||
}
|
||||
return colors[type] || 'text-gray-400'
|
||||
}
|
||||
|
||||
// Activity background for special events
|
||||
export function getActivityBgClass(type: ActivityType): string {
|
||||
if (type === 'event_start') {
|
||||
return 'bg-gradient-to-r from-yellow-900/30 to-orange-900/30 border-yellow-700/50'
|
||||
}
|
||||
if (type === 'event_end') {
|
||||
return 'bg-gray-800/50 border-gray-700/50'
|
||||
}
|
||||
return 'bg-gray-800/30 border-gray-700/30'
|
||||
}
|
||||
|
||||
// Check if activity is a special event
|
||||
export function isEventActivity(type: ActivityType): boolean {
|
||||
return type === 'event_start' || type === 'event_end'
|
||||
}
|
||||
|
||||
// Event type to Russian name mapping
|
||||
const EVENT_NAMES: Record<EventType, string> = {
|
||||
golden_hour: 'Золотой час',
|
||||
common_enemy: 'Общий враг',
|
||||
double_risk: 'Двойной риск',
|
||||
jackpot: 'Джекпот',
|
||||
swap: 'Обмен',
|
||||
rematch: 'Реванш',
|
||||
}
|
||||
|
||||
// Difficulty translation
|
||||
const DIFFICULTY_NAMES: Record<string, string> = {
|
||||
easy: 'Легкий',
|
||||
medium: 'Средний',
|
||||
hard: 'Сложный',
|
||||
}
|
||||
|
||||
interface Winner {
|
||||
nickname: string
|
||||
rank: number
|
||||
bonus_points: number
|
||||
}
|
||||
|
||||
// Format activity message
|
||||
export function formatActivityMessage(activity: Activity): { title: string; details?: string; extra?: string } {
|
||||
const data = activity.data || {}
|
||||
|
||||
switch (activity.type) {
|
||||
case 'join':
|
||||
return { title: 'присоединился к марафону' }
|
||||
|
||||
case 'spin': {
|
||||
const game = (data.game as string) || ''
|
||||
const challenge = (data.challenge as string) || ''
|
||||
const difficulty = data.difficulty ? DIFFICULTY_NAMES[data.difficulty as string] || '' : ''
|
||||
|
||||
return {
|
||||
title: 'получил задание',
|
||||
details: challenge || undefined,
|
||||
extra: [game, difficulty].filter(Boolean).join(' • ') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
const game = (data.game as string) || ''
|
||||
const challenge = (data.challenge as string) || ''
|
||||
const points = data.points ? `+${data.points}` : ''
|
||||
const streak = data.streak && (data.streak as number) > 1 ? `серия ${data.streak}` : ''
|
||||
const bonus = data.common_enemy_bonus ? `+${data.common_enemy_bonus} бонус` : ''
|
||||
|
||||
return {
|
||||
title: `завершил ${points}`,
|
||||
details: challenge || undefined,
|
||||
extra: [game, streak, bonus].filter(Boolean).join(' • ') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'drop': {
|
||||
const game = (data.game as string) || ''
|
||||
const challenge = (data.challenge as string) || ''
|
||||
const penalty = data.penalty ? `-${data.penalty}` : ''
|
||||
const free = data.free_drop ? '(бесплатно)' : ''
|
||||
|
||||
return {
|
||||
title: `пропустил ${penalty} ${free}`.trim(),
|
||||
details: challenge || undefined,
|
||||
extra: game || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'start_marathon':
|
||||
return { title: 'Марафон начался!' }
|
||||
|
||||
case 'finish_marathon':
|
||||
return { title: 'Марафон завершён!' }
|
||||
|
||||
case 'add_game':
|
||||
return {
|
||||
title: 'добавил игру',
|
||||
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||
}
|
||||
|
||||
case 'approve_game':
|
||||
return {
|
||||
title: 'одобрил игру',
|
||||
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||
}
|
||||
|
||||
case 'reject_game':
|
||||
return {
|
||||
title: 'отклонил игру',
|
||||
details: (data.game as string) || (data.game_title as string) || undefined,
|
||||
}
|
||||
|
||||
case 'event_start': {
|
||||
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
|
||||
return {
|
||||
title: 'СОБЫТИЕ НАЧАЛОСЬ',
|
||||
details: eventName,
|
||||
}
|
||||
}
|
||||
|
||||
case 'event_end': {
|
||||
const eventName = EVENT_NAMES[data.event_type as EventType] || (data.event_name as string) || 'Событие'
|
||||
const winners = data.winners as Winner[] | undefined
|
||||
|
||||
let winnersText = ''
|
||||
if (winners && winners.length > 0) {
|
||||
const medals = ['🥇', '🥈', '🥉']
|
||||
winnersText = winners
|
||||
.map((w) => `${medals[w.rank - 1] || ''} ${w.nickname} +${w.bonus_points}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Событие завершено',
|
||||
details: eventName,
|
||||
extra: winnersText || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'swap': {
|
||||
const challenge = (data.challenge as string) || ''
|
||||
const withUser = (data.with_user as string) || ''
|
||||
return {
|
||||
title: 'обменялся заданиями',
|
||||
details: withUser ? `с ${withUser}` : undefined,
|
||||
extra: challenge || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'rematch': {
|
||||
const game = (data.game as string) || ''
|
||||
const challenge = (data.challenge as string) || ''
|
||||
return {
|
||||
title: 'взял реванш',
|
||||
details: challenge || undefined,
|
||||
extra: game || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return { title: 'выполнил действие' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user