Files
game-marathon/frontend/src/components/ActivityFeed.tsx
2025-12-16 02:35:59 +07:00

283 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } 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 navigate = useNavigate()
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)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
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>
)}
{/* Details button and dispute indicator for complete activities */}
{assignmentId && (
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Детали
</button>
{disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Оспаривается
</span>
)}
{disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Отклонено
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}