/** * Component: Admin Requests Management Table * Documentation: documentation/admin-dashboard.md */ 'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { formatDistanceToNow } from 'date-fns'; import useSWR from 'swr'; import { ConfirmDialog } from './ConfirmDialog'; import { RequestActionsDropdown } from './RequestActionsDropdown'; import { mutate } from 'swr'; import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api'; import { useToast } from '@/components/ui/Toast'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; interface RecentRequest { requestId: string; title: string; author: string; asin?: string | null; status: string; type?: 'audiobook' | 'ebook'; userId: string; user: string; createdAt: Date; completedAt: Date | null; errorMessage: string | null; torrentUrl?: string | null; } interface User { id: string; plexUsername: string; } interface RequestsResponse { requests: RecentRequest[]; total: number; page: number; pageSize: number; totalPages: number; } interface RecentRequestsTableProps { ebookSidecarEnabled?: boolean; annasArchiveBaseUrl?: string; } const STATUS_OPTIONS = [ { value: 'all', label: 'All Statuses' }, { value: 'pending', label: 'Pending' }, { value: 'awaiting_approval', label: 'Awaiting Approval' }, { value: 'awaiting_search', label: 'Awaiting Search' }, { value: 'searching', label: 'Searching' }, { value: 'downloading', label: 'Downloading' }, { value: 'processing', label: 'Processing' }, { value: 'downloaded', label: 'Downloaded' }, { value: 'awaiting_import', label: 'Awaiting Import' }, { value: 'available', label: 'Available' }, { value: 'completed', label: 'Completed' }, { value: 'failed', label: 'Failed' }, { value: 'warn', label: 'Warning' }, { value: 'cancelled', label: 'Cancelled' }, { value: 'denied', label: 'Denied' }, ]; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; type SortField = 'createdAt' | 'completedAt' | 'title' | 'user' | 'status'; type SortOrder = 'asc' | 'desc'; function getStatusBadge(status: string) { const styles: Record = { pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', awaiting_approval: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-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', denied: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', }; 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', awaiting_approval: 'Awaiting Approval', }; const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1); return ( {label} ); } function SortIcon({ field, currentSort, currentOrder }: { field: SortField; currentSort: SortField; currentOrder: SortOrder }) { if (field !== currentSort) { return ( ); } return currentOrder === 'asc' ? ( ) : ( ); } // Helper to get initial params from URL (client-side only) function getInitialParams(): { page: number; pageSize: number; search: string; status: string; userId: string; sortBy: SortField; sortOrder: SortOrder; } { if (typeof window === 'undefined') { return { page: 1, pageSize: 25, search: '', status: 'all', userId: '', sortBy: 'createdAt', sortOrder: 'desc', }; } const params = new URLSearchParams(window.location.search); return { page: parseInt(params.get('page') || '1', 10), pageSize: parseInt(params.get('pageSize') || '25', 10), search: params.get('search') || '', status: params.get('status') || 'all', userId: params.get('userId') || '', sortBy: (params.get('sortBy') || 'createdAt') as SortField, sortOrder: (params.get('sortOrder') || 'desc') as SortOrder, }; } export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.li' }: RecentRequestsTableProps) { const toast = useToast(); // Get initial filter state from URL (only evaluated once due to lazy init) const [initialParams] = useState(getInitialParams); const [page, setPage] = useState(initialParams.page); const [pageSize, setPageSize] = useState(initialParams.pageSize); const [searchInput, setSearchInput] = useState(initialParams.search); const [debouncedSearch, setDebouncedSearch] = useState(initialParams.search); const [status, setStatus] = useState(initialParams.status); const [userId, setUserId] = useState(initialParams.userId); const [sortBy, setSortBy] = useState(initialParams.sortBy); const [sortOrder, setSortOrder] = useState(initialParams.sortOrder); // Track mounted state and last synced URL to handle browser back/forward const isMounted = useRef(false); const lastSyncedUrl = useRef(''); // Dialog states 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); // View Details modal state const [viewDetailsAsin, setViewDetailsAsin] = useState(null); const [viewDetailsStatus, setViewDetailsStatus] = useState(null); // Build API URL with current local filters const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(debouncedSearch)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`; // Fetch requests with SWR const { data, error, isLoading } = useSWR(apiUrl, authenticatedFetcher, { refreshInterval: 10000, keepPreviousData: true, // Keep showing old data while fetching new data to prevent layout shifts }); // Fetch users for filter dropdown const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher); // Build URL string for syncing const buildUrlString = useCallback((params: { page: number; pageSize: number; search: string; status: string; userId: string; sortBy: string; sortOrder: string; }) => { const pathname = typeof window !== 'undefined' ? window.location.pathname : '/admin'; const urlParams = new URLSearchParams(); if (params.page !== 1) urlParams.set('page', String(params.page)); if (params.pageSize !== 25) urlParams.set('pageSize', String(params.pageSize)); if (params.search) urlParams.set('search', params.search); if (params.status !== 'all') urlParams.set('status', params.status); if (params.userId) urlParams.set('userId', params.userId); if (params.sortBy !== 'createdAt') urlParams.set('sortBy', params.sortBy); if (params.sortOrder !== 'desc') urlParams.set('sortOrder', params.sortOrder); return urlParams.toString() ? `${pathname}?${urlParams.toString()}` : pathname; }, []); // Sync URL when filters change (shallow, doesn't cause re-render) useEffect(() => { if (!isMounted.current) { isMounted.current = true; return; } const newUrl = buildUrlString({ page, pageSize, search: debouncedSearch, status, userId, sortBy, sortOrder, }); if (newUrl !== lastSyncedUrl.current && typeof window !== 'undefined') { lastSyncedUrl.current = newUrl; window.history.replaceState(null, '', newUrl); } }, [page, pageSize, debouncedSearch, status, userId, sortBy, sortOrder, buildUrlString]); // Handle browser back/forward navigation useEffect(() => { const handlePopState = () => { const params = new URLSearchParams(window.location.search); setPage(parseInt(params.get('page') || '1', 10)); setPageSize(parseInt(params.get('pageSize') || '25', 10)); const newSearch = params.get('search') || ''; setSearchInput(newSearch); setDebouncedSearch(newSearch); setStatus(params.get('status') || 'all'); setUserId(params.get('userId') || ''); setSortBy((params.get('sortBy') || 'createdAt') as SortField); setSortOrder((params.get('sortOrder') || 'desc') as SortOrder); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Debounce search input useEffect(() => { const timer = setTimeout(() => { if (searchInput !== debouncedSearch) { setDebouncedSearch(searchInput); setPage(1); // Reset to page 1 on search change } }, 300); return () => clearTimeout(timer); }, [searchInput, debouncedSearch]); // Helper to update filters and reset page const updateFilter = useCallback((key: string, value: string | number) => { switch (key) { case 'status': setStatus(value as string); setPage(1); break; case 'userId': setUserId(value as string); setPage(1); break; case 'pageSize': setPageSize(value as number); setPage(1); break; case 'page': setPage(value as number); break; } }, []); const handleSort = (field: SortField) => { if (field === sortBy) { setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); } else { setSortBy(field); setSortOrder('desc'); } }; const clearFilters = () => { setSearchInput(''); setDebouncedSearch(''); setStatus('all'); setUserId(''); setPage(1); }; const hasActiveFilters = debouncedSearch || status !== 'all' || userId; // Action handlers const handleViewDetails = (asin: string, requestStatus?: string) => { setViewDetailsAsin(asin); setViewDetailsStatus(requestStatus || null); }; 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'); } // Refresh the requests list await mutate(apiUrl); await mutate('/api/admin/metrics'); await mutate((key) => typeof key === 'string' && key.includes('/api/audiobooks')); setShowDeleteConfirm(false); setSelectedRequest(null); toast.success('Request deleted successfully'); } 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'); } toast.success('Manual search triggered'); await mutate(apiUrl); } 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'); } toast.success('Request cancelled'); await mutate(apiUrl); } 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 responseData = await response.json(); if (!response.ok) { throw new Error(responseData.error || responseData.message || 'Failed to fetch e-book'); } if (responseData.success) { toast.success(responseData.message || 'E-book fetched successfully'); } else { toast.warning(`E-book fetch failed: ${responseData.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); } }; // Render loading state if (isLoading && !data) { return (
); } // Render error state if (error) { return (
Failed to load requests. Please try again.
); } const requests = data?.requests || []; const total = data?.total || 0; const totalPages = data?.totalPages || 1; // Calculate display range const startIndex = (page - 1) * pageSize + 1; const endIndex = Math.min(page * pageSize, total); return (
{/* Filter Bar */}
{/* Search Input */}
setSearchInput(e.target.value)} className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" />
{/* Status Filter */} {/* User Filter */} {/* Clear Filters Button */} {hasActiveFilters && ( )}
{/* Table */} {requests.length === 0 ? (

{hasActiveFilters ? 'No Matching Requests' : 'No Requests'}

{hasActiveFilters ? 'Try adjusting your filters or search terms.' : 'No audiobook requests have been made yet.'}

) : ( <>
{requests.map((request) => ( ))}
handleSort('title')} >
Request
handleSort('user')} >
User
handleSort('status')} >
Status
handleSort('createdAt')} >
Requested
handleSort('completedAt')} >
Completed
Actions
{request.title} {request.type === 'ebook' && ( Ebook )}
{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, }) : '-'} handleViewDetails(asin, request.status)} onFetchEbook={handleFetchEbook} ebookSidecarEnabled={ebookSidecarEnabled} annasArchiveBaseUrl={annasArchiveBaseUrl} isLoading={isDeleting || isFetchingEbook} />
{/* Pagination */}
{/* Results info */}
Showing {startIndex} to{' '} {endIndex} of{' '} {total} requests
{/* Page size selector */}
{/* Page navigation */}
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => { let pageNum: number; if (totalPages <= 5) { pageNum = i + 1; } else if (page <= 3) { pageNum = i + 1; } else if (page >= totalPages - 2) { pageNum = totalPages - 4 + i; } else { pageNum = page - 2 + i; } return ( ); })}
)} {/* 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} /> {/* Audiobook Details Modal */} {viewDetailsAsin && ( { setViewDetailsAsin(null); setViewDetailsStatus(null); }} isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'} requestStatus={viewDetailsStatus} hideRequestActions /> )} ); }