Merge branch 'main' into ebook-piecewise

This commit is contained in:
kikootwo
2026-02-02 10:33:20 -05:00
15 changed files with 1271 additions and 376 deletions
+489 -133
View File
@@ -1,16 +1,18 @@
/**
* Component: Admin Recent Requests Table
* Component: Admin Requests Management Table
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { formatDistanceToNow } from 'date-fns';
import useSWR from 'swr';
import { ConfirmDialog } from './ConfirmDialog';
import { RequestActionsDropdown } from './RequestActionsDropdown';
import { mutate } from 'swr';
import { fetchWithAuth } from '@/lib/utils/api';
import { authenticatedFetcher, fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface RecentRequest {
@@ -19,6 +21,7 @@ interface RecentRequest {
author: string;
status: string;
type?: 'audiobook' | 'ebook';
userId: string;
user: string;
createdAt: Date;
completedAt: Date | null;
@@ -26,14 +29,50 @@ interface RecentRequest {
torrentUrl?: string | null;
}
interface RecentRequestsTableProps {
interface User {
id: string;
plexUsername: string;
}
interface RequestsResponse {
requests: RecentRequest[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
interface RecentRequestsTableProps {
ebookSidecarEnabled?: boolean;
}
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<string, string> = {
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',
@@ -45,6 +84,7 @@ function getStatusBadge(status: string) {
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';
@@ -52,6 +92,7 @@ function getStatusBadge(status: string) {
const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search',
awaiting_import: 'Awaiting Import',
awaiting_approval: 'Awaiting Approval',
};
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
@@ -65,7 +106,45 @@ function getStatusBadge(status: string) {
);
}
export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: RecentRequestsTableProps) {
function SortIcon({ field, currentSort, currentOrder }: { field: SortField; currentSort: SortField; currentOrder: SortOrder }) {
if (field !== currentSort) {
return (
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
return currentOrder === 'asc' ? (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
) : (
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
export function RecentRequestsTable({ ebookSidecarEnabled = false }: RecentRequestsTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const toast = useToast();
// Get filter state from URL
const page = parseInt(searchParams.get('page') || '1', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '25', 10);
const search = searchParams.get('search') || '';
const status = searchParams.get('status') || 'all';
const userId = searchParams.get('userId') || '';
const sortBy = (searchParams.get('sortBy') || 'createdAt') as SortField;
const sortOrder = (searchParams.get('sortOrder') || 'desc') as SortOrder;
// Local search input state for debouncing
const [searchInput, setSearchInput] = useState(search);
// Dialog states
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<{
id: string;
@@ -73,8 +152,74 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
} | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isFetchingEbook, setIsFetchingEbook] = useState(false);
const toast = useToast();
// Build API URL with current filters
const apiUrl = `/api/admin/requests?page=${page}&pageSize=${pageSize}&search=${encodeURIComponent(search)}&status=${status}&userId=${userId}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
// Fetch requests with SWR
const { data, error, isLoading } = useSWR<RequestsResponse>(apiUrl, authenticatedFetcher, {
refreshInterval: 10000,
});
// Fetch users for filter dropdown
const { data: usersData } = useSWR<{ users: User[] }>('/api/admin/users', authenticatedFetcher);
// Update URL with new params
const updateParams = useCallback(
(updates: Record<string, string | number>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === '' || value === 'all' || (key === 'page' && value === 1) || (key === 'pageSize' && value === 25)) {
params.delete(key);
} else {
params.set(key, String(value));
}
});
// Reset to page 1 when filters change (except when changing page itself)
if (!('page' in updates)) {
params.delete('page');
}
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
router.push(newUrl, { scroll: false });
},
[pathname, router, searchParams]
);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
if (searchInput !== search) {
updateParams({ search: searchInput });
}
}, 300);
return () => clearTimeout(timer);
}, [searchInput, search, updateParams]);
// Sync search input with URL param on mount
useEffect(() => {
setSearchInput(search);
}, [search]);
const handleSort = (field: SortField) => {
if (field === sortBy) {
updateParams({ sortOrder: sortOrder === 'asc' ? 'desc' : 'asc' });
} else {
updateParams({ sortBy: field, sortOrder: 'desc' });
}
};
const clearFilters = () => {
setSearchInput('');
router.push(pathname, { scroll: false });
};
const hasActiveFilters = search || status !== 'all' || userId;
// Action handlers
const handleDeleteClick = (requestId: string, title: string) => {
setSelectedRequest({ id: requestId, title });
setShowDeleteConfirm(true);
@@ -98,21 +243,14 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
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(apiUrl);
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);
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'}`);
@@ -140,9 +278,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
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');
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'}`);
@@ -164,9 +301,8 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
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');
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'}`);
@@ -183,16 +319,16 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
},
});
const data = await response.json();
const responseData = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || 'Failed to fetch e-book');
throw new Error(responseData.error || responseData.message || 'Failed to fetch e-book');
}
if (data.success) {
toast.success(data.message || 'E-book fetched successfully');
if (responseData.success) {
toast.success(responseData.message || 'E-book fetched successfully');
} else {
toast.warning(`E-book fetch failed: ${data.message}`);
toast.warning(`E-book fetch failed: ${responseData.message}`);
}
} catch (error) {
console.error('[Admin] Failed to fetch e-book:', error);
@@ -202,16 +338,104 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
}
};
if (requests.length === 0) {
// Render loading state
if (isLoading && !data) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
<div className="flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
);
}
// Render error state
if (error) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center text-red-600 dark:text-red-400">
Failed to load requests. Please try again.
</div>
</div>
);
}
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 (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Filter Bar */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-col sm:flex-row gap-3">
{/* Search Input */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="Search by title or author..."
value={searchInput}
onChange={(e) => 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"
/>
</div>
{/* Status Filter */}
<select
value={status}
onChange={(e) => updateParams({ status: e.target.value })}
className="px-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 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* User Filter */}
<select
value={userId}
onChange={(e) => updateParams({ userId: e.target.value })}
className="px-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 focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm min-w-[160px]"
>
<option value="">All Users</option>
{usersData?.users.map((user) => (
<option key={user.id} value={user.id}>
{user.plexUsername}
</option>
))}
</select>
{/* Clear Filters Button */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
Clear
</button>
)}
</div>
</div>
{/* Table */}
{requests.length === 0 ? (
<div className="p-8 text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
@@ -221,115 +445,247 @@ export function RecentRequestsTable({ requests, ebookSidecarEnabled = false }: R
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
No Recent Requests
{hasActiveFilters ? 'No Matching Requests' : 'No Requests'}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
No audiobook requests have been made yet.
{hasActiveFilters
? 'Try adjusting your filters or search terms.'
: 'No audiobook requests have been made yet.'}
</p>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Request
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requested
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Completed
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr
key={request.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('title')}
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</span>
{request.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
Request
<SortIcon field="title" currentSort={sortBy} currentOrder={sortOrder} />
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('user')}
>
<div className="flex items-center gap-2">
User
<SortIcon field="user" currentSort={sortBy} currentOrder={sortOrder} />
</div>
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{request.errorMessage}
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-2">
Status
<SortIcon field="status" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('createdAt')}
>
<div className="flex items-center gap-2">
Requested
<SortIcon field="createdAt" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
onClick={() => handleSort('completedAt')}
>
<div className="flex items-center gap-2">
Completed
<SortIcon field="completedAt" currentSort={sortBy} currentOrder={sortOrder} />
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr
key={request.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</span>
{request.type === 'ebook' && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
style={{ backgroundColor: '#f16f1920', color: '#f16f19' }}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Ebook
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
</div>
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{request.errorMessage}
</div>
)}
</div>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{request.user}
</td>
<td className="px-6 py-4">{getStatusBadge(request.status)}</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{request.completedAt
? formatDistanceToNow(new Date(request.completedAt), {
addSuffix: true,
})
: '-'}
</td>
<td className="px-6 py-4">
<RequestActionsDropdown
request={{
requestId: request.requestId,
title: request.title,
author: request.author,
status: request.status,
type: request.type,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
{/* Results info */}
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{startIndex}</span> to{' '}
<span className="font-medium">{endIndex}</span> of{' '}
<span className="font-medium">{total}</span> requests
</div>
<div className="flex items-center gap-4">
{/* Page size selector */}
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 dark:text-gray-300">Show:</label>
<select
value={pageSize}
onChange={(e) => updateParams({ pageSize: parseInt(e.target.value, 10) })}
className="px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
{/* Page navigation */}
<div className="flex items-center gap-1">
<button
onClick={() => updateParams({ page: 1 })}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="First page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
onClick={() => updateParams({ page: page - 1 })}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Previous page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
{/* Page numbers */}
<div className="flex items-center gap-1 mx-2">
{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 (
<button
key={pageNum}
onClick={() => updateParams({ page: pageNum })}
className={`w-8 h-8 rounded-lg text-sm font-medium transition-colors ${
page === pageNum
? 'bg-blue-600 text-white'
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{pageNum}
</button>
);
})}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{request.user}
</td>
<td className="px-6 py-4">
{getStatusBadge(request.status)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{request.completedAt
? formatDistanceToNow(new Date(request.completedAt), {
addSuffix: true,
})
: '-'}
</td>
<td className="px-6 py-4">
<RequestActionsDropdown
request={{
requestId: request.requestId,
title: request.title,
author: request.author,
status: request.status,
type: request.type,
torrentUrl: request.torrentUrl,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onFetchEbook={handleFetchEbook}
ebookSidecarEnabled={ebookSidecarEnabled}
isLoading={isDeleting || isFetchingEbook}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => updateParams({ page: page + 1 })}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Next page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<button
onClick={() => updateParams({ page: totalPages })}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Last page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</div>
</>
)}
{/* Confirm Dialog */}
<ConfirmDialog