Sync from /srv/compose/unified-media-manager
This commit is contained in:
160
frontend/src/pages/Activity.tsx
Normal file
160
frontend/src/pages/Activity.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user