248 lines
7.9 KiB
TypeScript
248 lines
7.9 KiB
TypeScript
|
|
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>
|
|||
|
|
)
|
|||
|
|
}
|