161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { fetchAPI } from '../api/client'
|
|
import Pagination from '../components/Pagination'
|
|
import ErrorBanner from '../components/ErrorBanner'
|
|
import Loading from '../components/Loading'
|
|
|
|
interface ActivityEvent {
|
|
id: number
|
|
event_type: string
|
|
media_id: number | null
|
|
media_type: string | null
|
|
title: string
|
|
description: string | null
|
|
data: Record<string, unknown>
|
|
created_at: string
|
|
}
|
|
|
|
interface PaginatedResponse {
|
|
data: ActivityEvent[]
|
|
total: number
|
|
page: number
|
|
page_size: number
|
|
total_pages: number
|
|
}
|
|
|
|
const PAGE_SIZE = 50
|
|
|
|
function eventTypeBadge(type: string): { label: string; className: string } {
|
|
switch (type) {
|
|
case 'grab':
|
|
return { label: 'Grab', className: 'bg-blue-600' }
|
|
case 'import':
|
|
return { label: 'Import', className: 'bg-green-600' }
|
|
case 'download_complete':
|
|
return { label: 'Download', className: 'bg-green-600' }
|
|
case 'download_failed':
|
|
return { label: 'Failed', className: 'bg-red-600' }
|
|
case 'quality_upgrade':
|
|
return { label: 'Upgrade', className: 'bg-purple-600' }
|
|
case 'safety_block':
|
|
return { label: 'Blocked', className: 'bg-orange-600' }
|
|
case 'error':
|
|
return { label: 'Error', className: 'bg-red-600' }
|
|
default:
|
|
return { label: 'Info', className: 'bg-gray-600' }
|
|
}
|
|
}
|
|
|
|
function formatTimeAgo(dateStr: string): string {
|
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
const minutes = Math.floor(diff / 60000)
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
return `${days}d ago`
|
|
}
|
|
|
|
export default function Activity() {
|
|
const [events, setEvents] = useState<ActivityEvent[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [typeFilter, setTypeFilter] = useState('')
|
|
|
|
const fetchActivity = useCallback(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
let url = `/api/activity?page=${page}&page_size=${PAGE_SIZE}`
|
|
if (typeFilter) url += `&event_type=${typeFilter}`
|
|
fetchAPI<PaginatedResponse>(url)
|
|
.then(res => {
|
|
setEvents(res.data ?? [])
|
|
setTotal(res.total)
|
|
})
|
|
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
|
|
.finally(() => setLoading(false))
|
|
}, [page, typeFilter])
|
|
|
|
useEffect(() => {
|
|
fetchActivity()
|
|
}, [fetchActivity])
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-semibold text-gray-100">Activity</h2>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
|
|
className="bg-gray-800 border border-gray-700 text-gray-200 rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="">All Events</option>
|
|
<option value="grab">Grabs</option>
|
|
<option value="import">Imports</option>
|
|
<option value="download_complete">Downloads</option>
|
|
<option value="download_failed">Failures</option>
|
|
<option value="quality_upgrade">Upgrades</option>
|
|
<option value="safety_block">Safety Blocks</option>
|
|
<option value="error">Errors</option>
|
|
</select>
|
|
</div>
|
|
|
|
{error && <ErrorBanner error={error} onRetry={fetchActivity} />}
|
|
|
|
{loading ? (
|
|
<Loading />
|
|
) : events.length === 0 ? (
|
|
<div className="text-gray-500 text-center py-20 text-sm">No activity events</div>
|
|
) : (
|
|
<>
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-800">
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Type</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Media</th>
|
|
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{events.map(event => {
|
|
const badge = eventTypeBadge(event.event_type)
|
|
return (
|
|
<tr key={event.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
|
|
{badge.label}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm text-gray-100 truncate max-w-md">{event.title}</p>
|
|
{event.description && (
|
|
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-400">
|
|
{event.media_id != null ? `${event.media_type} #${event.media_id}` : '—'}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(event.created_at)}</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<Pagination
|
|
total={total}
|
|
page={page}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|