mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into ebook-piecewise
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user