Add events history
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user