/** * Component: Admin Recent Requests Table * Documentation: documentation/admin-dashboard.md */ 'use client'; import { useState } from 'react'; import { formatDistanceToNow } from 'date-fns'; import { ConfirmDialog } from './ConfirmDialog'; import { RequestActionsDropdown } from './RequestActionsDropdown'; import { mutate } from 'swr'; import { fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; interface RecentRequest { requestId: string; title: string; author: string; status: string; user: string; createdAt: Date; completedAt: Date | null; errorMessage: string | null; torrentUrl?: string | null; } interface RecentRequestsTableProps { requests: RecentRequest[]; ebookSidecarEnabled?: boolean; } function getStatusBadge(status: string) { const styles: Record = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', processing: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', awaiting_import: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', warn: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', }; const style = styles[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'; const labels: Record = { awaiting_search: 'Awaiting Search', awaiting_import: 'Awaiting Import', }; const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1); return ( {label} ); } export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [selectedRequest, setSelectedRequest] = useState<{ id: string; title: string; } | null>(null); const [isDeleting, setIsDeleting] = useState(false); const [isFetchingEbook, setIsFetchingEbook] = useState(false); const toast = useToast(); const handleDeleteClick = (requestId: string, title: string) => { setSelectedRequest({ id: requestId, title }); setShowDeleteConfirm(true); }; const handleDeleteConfirm = async () => { if (!selectedRequest) return; setIsDeleting(true); try { const response = await fetchWithAuth(`/api/admin/requests/${selectedRequest.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to delete request'); } const result = await response.json(); // Show success message console.log('[Admin] Request deleted:', result); // Refresh the requests list await mutate('/api/admin/requests/recent'); await mutate('/api/admin/metrics'); // Invalidate audiobook caches to update request status on home/search pages await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); // Close dialog setShowDeleteConfirm(false); setSelectedRequest(null); } catch (error) { console.error('[Admin] Failed to delete request:', error); toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsDeleting(false); } }; const handleDeleteCancel = () => { setShowDeleteConfirm(false); setSelectedRequest(null); }; const handleManualSearch = async (requestId: string) => { try { const response = await fetchWithAuth(`/api/requests/${requestId}/manual-search`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to trigger manual search'); } console.log('[Admin] Manual search triggered for request:', requestId); // Refresh the requests list await mutate('/api/admin/requests/recent'); } catch (error) { console.error('[Admin] Failed to trigger manual search:', error); toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; const handleCancel = async (requestId: string) => { try { const response = await fetchWithAuth(`/api/requests/${requestId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'cancel' }), }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.message || 'Failed to cancel request'); } console.log('[Admin] Request cancelled:', requestId); // Refresh the requests list await mutate('/api/admin/requests/recent'); } catch (error) { console.error('[Admin] Failed to cancel request:', error); toast.error(`Failed to cancel request: ${error instanceof Error ? error.message : 'Unknown error'}`); } }; const handleFetchEbook = async (requestId: string) => { setIsFetchingEbook(true); try { const response = await fetchWithAuth(`/api/requests/${requestId}/fetch-ebook`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || data.message || 'Failed to fetch e-book'); } if (data.success) { toast.success(data.message || 'E-book fetched successfully'); } else { toast.warning(`E-book fetch failed: ${data.message}`); } } catch (error) { console.error('[Admin] Failed to fetch e-book:', error); toast.error(`Failed to fetch e-book: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsFetchingEbook(false); } }; if (requests.length === 0) { return (

No Recent Requests

No audiobook requests have been made yet.

); } return (
{requests.map((request) => ( ))}
Audiobook User Status Requested Completed Actions
{request.title}
{request.author}
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
{request.errorMessage}
)}
{request.user} {getStatusBadge(request.status)} {formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })} {request.completedAt ? formatDistanceToNow(new Date(request.completedAt), { addSuffix: true, }) : '-'}
{/* Confirm Dialog */}

This will delete the request for "{selectedRequest.title}" and:

  • Remove the request (allowing it to be re-requested)
  • Delete files from the media directory
  • Keep torrent seeding if time remaining

Are you sure?

) : ( '' ) } confirmLabel={isDeleting ? 'Deleting...' : 'Delete'} cancelLabel="Cancel" confirmVariant="danger" onConfirm={handleDeleteConfirm} onCancel={handleDeleteCancel} /> ); }