Add custom search terms & retry download (admin)

Add support for per-request custom search terms and an admin retry-download flow.

- DB/schema: add custom_search_terms column via Prisma migration and schema update.
- Admin UI: new AdjustSearchTermsModal component and UI badges to show custom search status; RequestActionsDropdown and RecentRequestsTable updated to surface adjust/retry actions.
- API: new PATCH /api/admin/requests/[id]/search-terms to set/clear custom terms (optionally trigger a new search) and new POST /api/admin/requests/[id]/retry-download to resume monitoring or re-add downloads using DownloadHistory metadata.
- Behavior: interactive search now prefers customSearchTerms when present; manual import exposes cleanupSource option to organize job; admin requests listing returns downloadAttempts and customSearchTerms.
- UX: add SectionToolbar, LoadMoreBar and HideAvailableToggle components and wire hide-available preference across home, search, author and series pages; authors/series endpoints/page handlers gain pagination metadata.
- Misc: add connection-errors util and update related processors/services and tests to cover the new flows.

These changes enable admins to override search terms per request, trigger searches from the admin UI, and retry failed downloads more robustly.
This commit is contained in:
kikootwo
2026-03-02 17:05:21 -05:00
parent 3ee67c8763
commit d25a6ebf79
39 changed files with 2034 additions and 311 deletions
@@ -0,0 +1,154 @@
/**
* Component: Adjust Search Terms Modal
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import { Modal } from '@/components/ui/Modal';
import { fetchWithAuth } from '@/lib/utils/api';
import { useToast } from '@/components/ui/Toast';
interface AdjustSearchTermsModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
title: string;
author: string;
currentSearchTerms?: string | null;
onSuccess?: () => void;
}
export function AdjustSearchTermsModal({
isOpen,
onClose,
requestId,
title,
author,
currentSearchTerms,
onSuccess,
}: AdjustSearchTermsModalProps) {
const toast = useToast();
const [searchTerms, setSearchTerms] = useState(currentSearchTerms || title);
const [isSaving, setIsSaving] = useState(false);
const [isSavingAndSearching, setIsSavingAndSearching] = useState(false);
// Reset state when modal opens
const handleClose = () => {
setSearchTerms(currentSearchTerms || title);
onClose();
};
const save = async (triggerSearch: boolean) => {
const setter = triggerSearch ? setIsSavingAndSearching : setIsSaving;
setter(true);
try {
// If terms match the original title, clear the override
const termsToSave = searchTerms.trim() === title ? null : searchTerms.trim() || null;
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/search-terms`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: termsToSave, triggerSearch }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update search terms');
}
const data = await response.json();
if (data.searchTriggered) {
toast.success('Search terms saved and search triggered');
} else {
toast.success('Search terms saved');
}
onSuccess?.();
onClose();
} catch (error) {
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setter(false);
}
};
const handleReset = () => {
setSearchTerms(title);
};
const isLoading = isSaving || isSavingAndSearching;
const hasChanges = searchTerms.trim() !== (currentSearchTerms || title);
const isCustom = searchTerms.trim() !== title;
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Adjust Search Terms" size="sm">
<div className="space-y-4">
{/* Original info */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 space-y-1">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Original Title
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 font-medium">{title}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">by {author}</div>
</div>
{/* Search terms input */}
<div>
<label
htmlFor="search-terms"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
>
Search Terms
</label>
<input
id="search-terms"
type="text"
value={searchTerms}
onChange={(e) => setSearchTerms(e.target.value)}
disabled={isLoading}
className="w-full 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 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="Enter custom search terms..."
/>
{isCustom && (
<button
onClick={handleReset}
disabled={isLoading}
className="mt-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-50"
>
Reset to original title
</button>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleClose}
disabled={isLoading}
className="px-4 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 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => save(false)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 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 disabled:opacity-50"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => save(true)}
disabled={isLoading || !searchTerms.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{isSavingAndSearching ? 'Saving...' : 'Save & Search'}
</button>
</div>
</div>
</Modal>
);
}
@@ -28,6 +28,8 @@ interface RecentRequest {
completedAt: Date | null;
errorMessage: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
}
interface User {
@@ -444,6 +446,29 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}
};
const handleRetryDownload = async (requestId: string) => {
try {
const response = await fetchWithAuth(`/api/admin/requests/${requestId}/retry-download`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(responseData.message || 'Failed to retry download');
}
toast.success(responseData.message || 'Download retry initiated');
await mutate(apiUrl);
} catch (error) {
console.error('[Admin] Failed to retry download:', error);
toast.error(`Failed to retry download: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
// Render loading state
if (isLoading && !data) {
return (
@@ -638,6 +663,17 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
Ebook
</span>
)}
{request.customSearchTerms && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200"
title={`Custom search: ${request.customSearchTerms}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Custom Search
</span>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
@@ -673,12 +709,16 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
type: request.type,
asin: request.asin,
torrentUrl: request.torrentUrl,
downloadAttempts: request.downloadAttempts,
customSearchTerms: request.customSearchTerms,
}}
onDelete={handleDeleteClick}
onManualSearch={handleManualSearch}
onCancel={handleCancel}
onRetryDownload={handleRetryDownload}
onViewDetails={(asin) => handleViewDetails(asin, request.status)}
onFetchEbook={handleFetchEbook}
onSearchTermsUpdated={() => mutate(apiUrl)}
ebookSidecarEnabled={ebookSidecarEnabled}
annasArchiveBaseUrl={annasArchiveBaseUrl}
isLoading={isDeleting || isFetchingEbook}
@@ -835,7 +875,6 @@ export function RecentRequestsTable({ ebookSidecarEnabled = false, annasArchiveB
}}
isAvailable={viewDetailsStatus === 'available' || viewDetailsStatus === 'completed'}
requestStatus={viewDetailsStatus}
hideRequestActions
/>
)}
</div>
@@ -10,6 +10,7 @@
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { AdjustSearchTermsModal } from './AdjustSearchTermsModal';
import { useSmartDropdownPosition } from '@/hooks/useSmartDropdownPosition';
export interface RequestActionsDropdownProps {
@@ -21,12 +22,16 @@ export interface RequestActionsDropdownProps {
type?: 'audiobook' | 'ebook';
asin?: string | null;
torrentUrl?: string | null;
downloadAttempts?: number;
customSearchTerms?: string | null;
};
onDelete: (requestId: string, title: string) => void;
onManualSearch: (requestId: string) => Promise<void>;
onCancel: (requestId: string) => Promise<void>;
onRetryDownload?: (requestId: string) => Promise<void>;
onViewDetails?: (asin: string) => void;
onFetchEbook?: (requestId: string) => Promise<void>;
onSearchTermsUpdated?: () => void;
ebookSidecarEnabled?: boolean;
annasArchiveBaseUrl?: string;
isLoading?: boolean;
@@ -37,8 +42,10 @@ export function RequestActionsDropdown({
onDelete,
onManualSearch,
onCancel,
onRetryDownload,
onViewDetails,
onFetchEbook,
onSearchTermsUpdated,
ebookSidecarEnabled = false,
annasArchiveBaseUrl = 'https://annas-archive.li',
isLoading = false,
@@ -46,6 +53,7 @@ export function RequestActionsDropdown({
const [isOpen, setIsOpen] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showAdjustSearchTerms, setShowAdjustSearchTerms] = useState(false);
const { containerRef, dropdownRef, positionAbove, style } = useSmartDropdownPosition(isOpen);
// Determine request type
@@ -57,6 +65,8 @@ export function RequestActionsDropdown({
// Determine available actions based on status and type
// Ebooks don't support manual/interactive search (Anna's Archive only)
const canSearch = !isEbook && ['pending', 'failed', 'awaiting_search'].includes(request.status);
const canAdjustSearchTerms = !isEbook && ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status);
const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload;
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
const canDelete = true; // Admins can always delete
@@ -123,11 +133,27 @@ export function RequestActionsDropdown({
setShowInteractiveSearch(true);
};
const handleAdjustSearchTerms = () => {
setIsOpen(false);
setShowAdjustSearchTerms(true);
};
const handleInteractiveSearchEbook = () => {
setIsOpen(false);
setShowInteractiveSearchEbook(true);
};
const handleRetryDownload = async () => {
setIsOpen(false);
if (onRetryDownload) {
try {
await onRetryDownload(request.requestId);
} catch (error) {
console.error('Failed to retry download:', error);
}
}
};
const handleCancel = async () => {
setIsOpen(false);
if (window.confirm(`Are you sure you want to cancel the request for "${request.title}"?`)) {
@@ -253,6 +279,35 @@ export function RequestActionsDropdown({
</button>
)}
{/* Adjust Search Terms */}
{canAdjustSearchTerms && (
<button
onClick={handleAdjustSearchTerms}
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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span className="flex items-center gap-1.5">
Adjust Search Terms
{request.customSearchTerms && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />
)}
</span>
</button>
)}
{/* View Source */}
{canViewSource && viewSourceUrl && (
<a
@@ -328,8 +383,32 @@ export function RequestActionsDropdown({
</button>
)}
{/* Divider if we have search/view actions and other actions */}
{(canSearch || canViewSource || canFetchEbook) && (canCancel || canDelete) && (
{/* Retry Download */}
{canRetryDownload && (
<button
onClick={handleRetryDownload}
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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Retry Download
</button>
)}
{/* Divider if we have search/view/retry actions and other actions */}
{(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -358,7 +437,7 @@ export function RequestActionsDropdown({
)}
{/* Divider before delete */}
{canDelete && (canSearch || canCancel) && (
{canDelete && (canSearch || canRetryDownload || canCancel) && (
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
)}
@@ -421,6 +500,7 @@ export function RequestActionsDropdown({
title: request.title,
author: request.author,
}}
customSearchTerms={request.customSearchTerms}
/>
{/* Interactive Search Modal (Ebook) */}
@@ -434,6 +514,17 @@ export function RequestActionsDropdown({
}}
searchMode="ebook"
/>
{/* Adjust Search Terms Modal */}
<AdjustSearchTermsModal
isOpen={showAdjustSearchTerms}
onClose={() => setShowAdjustSearchTerms(false)}
requestId={request.requestId}
title={request.title}
author={request.author}
currentSearchTerms={request.customSearchTerms}
onSuccess={onSearchTermsUpdated}
/>
</>
);
}