283 lines
9.4 KiB
TypeScript
283 lines
9.4 KiB
TypeScript
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>
|
||
)
|
||
}
|