mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add e-book fetch API and UI integration for requests
Introduces an API endpoint to trigger e-book downloads for completed requests, with admin UI integration in RecentRequestsTable and RequestActionsDropdown. Updates the admin dashboard to detect e-book sidecar feature availability from settings. Enhances torrent search result handling with info URLs, improves ranking algorithm normalization, and refines interactive search to show all results without threshold filtering. Also allows nullable ratings in request schemas.
This commit is contained in:
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -26,6 +27,7 @@ interface RecentRequest {
|
||||
|
||||
interface RecentRequestsTableProps {
|
||||
requests: RecentRequest[];
|
||||
ebookSidecarEnabled?: boolean;
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
@@ -62,13 +64,15 @@ function getStatusBadge(status: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
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 });
|
||||
@@ -110,11 +114,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
setSelectedRequest(null);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to delete request:', error);
|
||||
alert(
|
||||
`Failed to delete request: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
toast.error(`Failed to delete request: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -144,11 +144,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
await mutate('/api/admin/requests/recent');
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to trigger manual search:', error);
|
||||
alert(
|
||||
`Failed to trigger manual search: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
toast.error(`Failed to trigger manual search: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,11 +168,36 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
await mutate('/api/admin/requests/recent');
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to cancel request:', error);
|
||||
alert(
|
||||
`Failed to cancel request: ${
|
||||
error instanceof Error ? error.message : 'Unknown 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,7 +303,9 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
onCancel={handleCancel}
|
||||
isLoading={isDeleting}
|
||||
onFetchEbook={handleFetchEbook}
|
||||
ebookSidecarEnabled={ebookSidecarEnabled}
|
||||
isLoading={isDeleting || isFetchingEbook}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface RequestActionsDropdownProps {
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
onCancel: (requestId: string) => Promise<void>;
|
||||
onFetchEbook?: (requestId: string) => Promise<void>;
|
||||
ebookSidecarEnabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,8 @@ export function RequestActionsDropdown({
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onFetchEbook,
|
||||
ebookSidecarEnabled = false,
|
||||
isLoading = false,
|
||||
}: RequestActionsDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -40,6 +44,7 @@ export function RequestActionsDropdown({
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
const canFetchEbook = ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -88,6 +93,17 @@ export function RequestActionsDropdown({
|
||||
onDelete(request.requestId, request.title);
|
||||
};
|
||||
|
||||
const handleFetchEbook = async () => {
|
||||
setIsOpen(false);
|
||||
if (onFetchEbook) {
|
||||
try {
|
||||
await onFetchEbook(request.requestId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch e-book:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Three-dot menu button */}
|
||||
@@ -185,8 +201,32 @@ export function RequestActionsDropdown({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Fetch E-book */}
|
||||
{canFetchEbook && (
|
||||
<button
|
||||
onClick={handleFetchEbook}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
Try to fetch Ebook
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource) && (canCancel || canDelete) && (
|
||||
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
|
||||
+22
-3
@@ -5,15 +5,15 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import Link from 'next/link';
|
||||
import { authenticatedFetcher } from '@/lib/utils/api';
|
||||
import { MetricCard } from './components/MetricCard';
|
||||
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
|
||||
import { RecentRequestsTable } from './components/RecentRequestsTable';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
function AdminDashboardContent() {
|
||||
// Fetch data with auto-refresh every 10 seconds
|
||||
const { data: metrics, error: metricsError } = useSWR(
|
||||
'/api/admin/metrics',
|
||||
@@ -39,6 +39,14 @@ export default function AdminDashboard() {
|
||||
}
|
||||
);
|
||||
|
||||
const { data: settingsData } = useSWR(
|
||||
'/api/admin/settings',
|
||||
authenticatedFetcher,
|
||||
{
|
||||
refreshInterval: 60000, // Settings change infrequently
|
||||
}
|
||||
);
|
||||
|
||||
const isLoading = !metrics || !downloadsData || !requestsData;
|
||||
const hasError = metricsError || downloadsError || requestsError;
|
||||
|
||||
@@ -202,7 +210,10 @@ export default function AdminDashboard() {
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Recent Requests
|
||||
</h2>
|
||||
<RecentRequestsTable requests={requestsData.requests} />
|
||||
<RecentRequestsTable
|
||||
requests={requestsData.requests}
|
||||
ebookSidecarEnabled={settingsData?.ebook?.enabled || false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
@@ -298,3 +309,11 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AdminDashboardContent />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user