Add events history
This commit is contained in:
@@ -11,7 +11,7 @@ from app.core.config import settings
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||||
EventType, Difficulty
|
EventType, Difficulty, User
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
@@ -130,6 +130,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
activity_data = {
|
activity_data = {
|
||||||
"game": game.title,
|
"game": game.title,
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
|
"points": challenge.points,
|
||||||
}
|
}
|
||||||
if active_event:
|
if active_event:
|
||||||
activity_data["event_type"] = active_event.type
|
activity_data["event_type"] = active_event.type
|
||||||
@@ -328,6 +330,7 @@ async def complete_assignment(
|
|||||||
db, active_event, participant.id, current_user.id
|
db, active_event, participant.id, current_user.id
|
||||||
)
|
)
|
||||||
total_points += common_enemy_bonus
|
total_points += common_enemy_bonus
|
||||||
|
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
|
||||||
|
|
||||||
# Update assignment
|
# Update assignment
|
||||||
assignment.status = AssignmentStatus.COMPLETED.value
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
@@ -342,7 +345,9 @@ async def complete_assignment(
|
|||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity_data = {
|
activity_data = {
|
||||||
|
"game": full_challenge.game.title,
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
|
"difficulty": challenge.difficulty,
|
||||||
"points": total_points,
|
"points": total_points,
|
||||||
"streak": participant.current_streak,
|
"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 event auto-closed, log the event end with winners
|
||||||
if common_enemy_closed and common_enemy_winners:
|
if common_enemy_closed and common_enemy_winners:
|
||||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
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(
|
event_end_activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id, # Last completer triggers the close
|
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_type": EventType.COMMON_ENEMY.value,
|
||||||
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
|
||||||
"auto_closed": True,
|
"auto_closed": True,
|
||||||
"winners": [
|
"winners": winners_data,
|
||||||
{
|
|
||||||
"user_id": w["user_id"],
|
|
||||||
"rank": w["rank"],
|
|
||||||
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
|
|
||||||
}
|
|
||||||
for w in common_enemy_winners
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
db.add(event_end_activity)
|
db.add(event_end_activity)
|
||||||
@@ -440,7 +456,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity_data = {
|
activity_data = {
|
||||||
|
"game": assignment.challenge.game.title,
|
||||||
"challenge": assignment.challenge.title,
|
"challenge": assignment.challenge.title,
|
||||||
|
"difficulty": assignment.challenge.difficulty,
|
||||||
"penalty": penalty,
|
"penalty": penalty,
|
||||||
}
|
}
|
||||||
if active_event:
|
if active_event:
|
||||||
|
|||||||
@@ -157,13 +157,16 @@ class EventService:
|
|||||||
- winners_list: list of winners if event closed, None otherwise
|
- winners_list: list of winners if event closed, None otherwise
|
||||||
"""
|
"""
|
||||||
if event.type != EventType.COMMON_ENEMY.value:
|
if event.type != EventType.COMMON_ENEMY.value:
|
||||||
|
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
|
||||||
return 0, False, None
|
return 0, False, None
|
||||||
|
|
||||||
data = event.data or {}
|
data = event.data or {}
|
||||||
completions = data.get("completions", [])
|
completions = data.get("completions", [])
|
||||||
|
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
|
||||||
|
|
||||||
# Check if already completed
|
# Check if already completed
|
||||||
if any(c["participant_id"] == participant_id for c in completions):
|
if any(c["participant_id"] == participant_id for c in completions):
|
||||||
|
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
|
||||||
return 0, False, None
|
return 0, False, None
|
||||||
|
|
||||||
# Add completion
|
# Add completion
|
||||||
@@ -174,6 +177,7 @@ class EventService:
|
|||||||
"completed_at": datetime.utcnow().isoformat(),
|
"completed_at": datetime.utcnow().isoformat(),
|
||||||
"rank": rank,
|
"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
|
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
|
||||||
event.data = {**data, "completions": completions}
|
event.data = {**data, "completions": completions}
|
||||||
@@ -189,6 +193,7 @@ class EventService:
|
|||||||
event.end_time = datetime.utcnow()
|
event.end_time = datetime.utcnow()
|
||||||
event_closed = True
|
event_closed = True
|
||||||
winners_list = completions[:3] # Top 3
|
winners_list = completions[:3] # Top 3
|
||||||
|
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
|
||||||
|
|
||||||
await db.commit()
|
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;
|
@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 {
|
@layer components {
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
@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 { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||||
@@ -6,6 +6,7 @@ import { Button, Card, CardContent } from '@/components/ui'
|
|||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { EventControl } from '@/components/EventControl'
|
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 { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ export function MarathonPage() {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
const [showEventControl, setShowEventControl] = useState(false)
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathon()
|
loadMarathon()
|
||||||
@@ -60,6 +62,8 @@ export function MarathonPage() {
|
|||||||
try {
|
try {
|
||||||
const eventData = await eventsApi.getActive(parseInt(id))
|
const eventData = await eventsApi.getActive(parseInt(id))
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
|
// Refresh activity feed when event changes
|
||||||
|
activityFeedRef.current?.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh event:', error)
|
console.error('Failed to refresh event:', error)
|
||||||
}
|
}
|
||||||
@@ -122,243 +126,261 @@ export function MarathonPage() {
|
|||||||
const canDelete = isCreator || user?.role === 'admin'
|
const canDelete = isCreator || user?.role === 'admin'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
<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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
К списку марафонов
|
К списку марафонов
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
<div className="flex justify-between items-start mb-8">
|
{/* Main content */}
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
{/* Header */}
|
||||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
<div className="flex justify-between items-start mb-8">
|
||||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
<div>
|
||||||
marathon.is_public
|
<div className="flex items-center gap-3 mb-2">
|
||||||
? 'bg-green-900/50 text-green-400'
|
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||||
: 'bg-gray-700 text-gray-300'
|
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||||
}`}>
|
marathon.is_public
|
||||||
{marathon.is_public ? (
|
? 'bg-green-900/50 text-green-400'
|
||||||
<><Globe className="w-3 h-3" /> Открытый</>
|
: 'bg-gray-700 text-gray-300'
|
||||||
) : (
|
}`}>
|
||||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
{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>
|
</div>
|
||||||
{marathon.description && (
|
|
||||||
<p className="text-gray-400">{marathon.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{/* Stats */}
|
||||||
{/* Кнопка присоединиться для открытых марафонов */}
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
<Card>
|
||||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
<CardContent className="text-center py-4">
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
<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">
|
||||||
</Button>
|
<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 === 'preparing' && isOrganizer && (
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Card className="mb-8">
|
||||||
<Button variant="secondary">
|
<CardContent>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<div className="flex items-center justify-between mb-4">
|
||||||
Настройка
|
<h3 className="font-medium text-white flex items-center gap-2">
|
||||||
</Button>
|
<Zap className="w-5 h-5 text-yellow-500" />
|
||||||
</Link>
|
Управление событиями
|
||||||
)}
|
</h3>
|
||||||
|
<Button
|
||||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
variant="ghost"
|
||||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
size="sm"
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
onClick={() => setShowEventControl(!showEventControl)}
|
||||||
<Button variant="secondary">
|
>
|
||||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||||
Предложить игру
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</Link>
|
{showEventControl && activeEvent && (
|
||||||
)}
|
<EventControl
|
||||||
|
marathonId={marathon.id}
|
||||||
{marathon.status === 'active' && isParticipant && (
|
activeEvent={activeEvent}
|
||||||
<Link to={`/marathons/${id}/play`}>
|
challenges={challenges}
|
||||||
<Button>
|
onEventChange={refreshEvent}
|
||||||
<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" />
|
|
||||||
Копировать
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
)}
|
||||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* My stats */}
|
{/* Invite link */}
|
||||||
{marathon.my_participation && (
|
{marathon.status !== 'finished' && (
|
||||||
<Card>
|
<Card className="mb-8">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="flex items-center gap-3">
|
||||||
<div>
|
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
||||||
<div className="text-2xl font-bold text-primary-500">
|
{getInviteLink()}
|
||||||
{marathon.my_participation.total_points}
|
</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>
|
||||||
<div className="text-sm text-gray-400">Очков</div>
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
</div>
|
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
||||||
<div>
|
</p>
|
||||||
<div className="text-2xl font-bold text-yellow-500">
|
</CardContent>
|
||||||
{marathon.my_participation.current_streak}
|
</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>
|
||||||
<div className="text-sm text-gray-400">Серия</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<div>
|
)}
|
||||||
<div className="text-2xl font-bold text-gray-400">
|
</div>
|
||||||
{marathon.my_participation.drop_count}
|
|
||||||
</div>
|
{/* Activity Feed - right sidebar */}
|
||||||
<div className="text-sm text-gray-400">Пропусков</div>
|
{isParticipant && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,10 @@ export type ActivityType =
|
|||||||
| 'add_game'
|
| 'add_game'
|
||||||
| 'approve_game'
|
| 'approve_game'
|
||||||
| 'reject_game'
|
| 'reject_game'
|
||||||
|
| 'event_start'
|
||||||
|
| 'event_end'
|
||||||
|
| 'swap'
|
||||||
|
| 'rematch'
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
id: number
|
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