Files
game-marathon/frontend/src/components/ActivityFeed.tsx

283 lines
9.4 KiB
TypeScript
Raw Normal View History

2025-12-15 22:31:42 +07:00
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
2025-12-16 00:33:50 +07:00
import { useNavigate } from 'react-router-dom'
2025-12-15 22:31:42 +07:00
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
2025-12-16 02:35:59 +07:00
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
2025-12-15 22:31:42 +07:00
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) {
2025-12-16 00:33:50 +07:00
const navigate = useNavigate()
2025-12-15 22:31:42 +07:00
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)
2025-12-16 02:35:59 +07:00
// 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
2025-12-16 00:33:50 +07:00
: null
2025-12-15 22:31:42 +07:00
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>
)}
2025-12-16 02:35:59 +07:00
{/* Details button and dispute indicator for complete activities */}
2025-12-16 00:33:50 +07:00
{assignmentId && (
2025-12-16 02:35:59 +07:00
<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>
2025-12-16 00:33:50 +07:00
)}
2025-12-15 22:31:42 +07:00
</div>
</div>
</div>
)
}