mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add ROOTLESS_CONTAINER and request UI updates
Introduce ROOTLESS_CONTAINER env to opt out of gosu (replace /proc uid_map detection) and update entrypoint messaging; adjust app-start.sh and redis-start.sh to skip gosu when ROOTLESS_CONTAINER=true and warn on UID/GID mismatch only when applicable. Backend: include audiobook audibleAsin in admin requests response (mapped to asin) and pass baseUrl through test-flaresolverr endpoint to the FlareSolverr tester. Frontend: RecentRequestsTable and RequestActionsDropdown now surface asin, accept/passthrough annasArchiveBaseUrl, and add a "View Details" flow using AudiobookDetailsModal; admin page passes ebook baseUrl from settings. InteractiveTorrentSearchModal refactor: improved UX/UI, keyboard handling, portal/modal mounting, skeleton/loading states, formatting helpers, and richer result display. Tests updated to match changes.
This commit is contained in:
@@ -9,10 +9,8 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ConfirmModal } from '@/components/ui/ConfirmModal';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TorrentResult, RankedTorrent } from '@/lib/utils/ranking-algorithm';
|
||||
import {
|
||||
useInteractiveSearch,
|
||||
@@ -40,6 +38,46 @@ interface InteractiveTorrentSearchModalProps {
|
||||
searchMode?: 'audiobook' | 'ebook'; // Search mode - defaults to audiobook
|
||||
}
|
||||
|
||||
// Format relative time from publish date
|
||||
const formatAge = (date: Date | string): string => {
|
||||
const now = new Date();
|
||||
const d = new Date(date);
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
if (diffMs < 0) return 'Soon';
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return 'Today';
|
||||
if (diffDays === 1) return '1d ago';
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
const months = Math.floor(diffDays / 30.44);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
const years = Math.floor(diffDays / 365.25);
|
||||
return `${years}y ago`;
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatSize = (bytes: number): string => {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
// Score badge color scheme
|
||||
const getScoreStyle = (score: number) => {
|
||||
if (score >= 90) return { bg: 'bg-emerald-500/15 dark:bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-400' };
|
||||
if (score >= 70) return { bg: 'bg-blue-500/15 dark:bg-blue-400/15', text: 'text-blue-700 dark:text-blue-400' };
|
||||
if (score >= 50) return { bg: 'bg-amber-500/15 dark:bg-amber-400/15', text: 'text-amber-700 dark:text-amber-400' };
|
||||
return { bg: 'bg-gray-500/10 dark:bg-gray-400/10', text: 'text-gray-500 dark:text-gray-400' };
|
||||
};
|
||||
|
||||
// Skeleton widths for loading state (deterministic to avoid hydration mismatch)
|
||||
const skeletonRows = [
|
||||
{ title: '72%', meta: '48%' },
|
||||
{ title: '85%', meta: '58%' },
|
||||
{ title: '64%', meta: '42%' },
|
||||
{ title: '78%', meta: '52%' },
|
||||
{ title: '68%', meta: '45%' },
|
||||
];
|
||||
|
||||
export function InteractiveTorrentSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -66,9 +104,15 @@ export function InteractiveTorrentSearchModal({
|
||||
const { searchEbooks: searchEbooksByAsin, isLoading: isSearchingEbooksByAsin, error: searchEbooksByAsinError } = useInteractiveSearchEbookByAsin();
|
||||
const { selectEbook: selectEbookByAsin, isLoading: isSelectingEbookByAsin, error: selectEbookByAsinError } = useSelectEbookByAsin();
|
||||
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string })[]>([]);
|
||||
const [results, setResults] = useState<(RankedTorrent & { qualityScore?: number; source?: string; ebookFormat?: string })[]>([]);
|
||||
const [confirmTorrent, setConfirmTorrent] = useState<TorrentResult | null>(null);
|
||||
const [searchTitle, setSearchTitle] = useState(audiobook.title);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Stable close handler via ref
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const handleClose = useCallback(() => { onCloseRef.current(); }, []);
|
||||
|
||||
// Determine which mode we're in
|
||||
const isEbookMode = searchMode === 'ebook';
|
||||
@@ -89,58 +133,72 @@ export function InteractiveTorrentSearchModal({
|
||||
? (searchByRequestError || selectTorrentError)
|
||||
: (searchByAudiobookError || requestWithTorrentError));
|
||||
|
||||
// Mount tracking for portal
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
// Reset search title when modal opens/closes or audiobook changes
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setSearchTitle(audiobook.title);
|
||||
setResults([]);
|
||||
}, [isOpen, audiobook.title]);
|
||||
|
||||
// Perform search when modal opens
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (isOpen && results.length === 0) {
|
||||
performSearch();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const performSearch = async () => {
|
||||
// Clear existing results while searching
|
||||
setResults([]);
|
||||
// ESC key and body scroll lock
|
||||
// ESC dismisses confirmation first, then closes modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (confirmTorrent) {
|
||||
setConfirmTorrent(null);
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEsc);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleClose, confirmTorrent]);
|
||||
|
||||
const performSearch = async () => {
|
||||
setResults([]);
|
||||
try {
|
||||
let data;
|
||||
if (isEbookMode) {
|
||||
// Ebook mode: search Anna's Archive + indexers
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
if (useAsinMode && asin) {
|
||||
// ASIN-based ebook search (user flow from details modal)
|
||||
data = await searchEbooksByAsin(asin, customTitle);
|
||||
} else if (requestId) {
|
||||
// Request ID-based ebook search (admin flow)
|
||||
data = await searchEbooks(requestId, customTitle);
|
||||
} else {
|
||||
console.error('Ebook search requires either requestId or asin');
|
||||
return;
|
||||
}
|
||||
} else if (hasRequestId) {
|
||||
// Existing audiobook flow: search by requestId with optional custom title
|
||||
const customTitle = searchTitle !== audiobook.title ? searchTitle : undefined;
|
||||
data = await searchByRequestId(requestId, customTitle);
|
||||
} else {
|
||||
// New audiobook flow: search by custom title + original author + optional ASIN for size scoring
|
||||
const audiobookAsin = fullAudiobook?.asin;
|
||||
data = await searchByAudiobook(searchTitle, audiobook.author, audiobookAsin);
|
||||
}
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
// Error already handled by hook
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') performSearch();
|
||||
};
|
||||
|
||||
const handleDownloadClick = (torrent: TorrentResult) => {
|
||||
@@ -149,270 +207,385 @@ export function InteractiveTorrentSearchModal({
|
||||
|
||||
const handleConfirmDownload = async () => {
|
||||
if (!confirmTorrent) return;
|
||||
|
||||
try {
|
||||
if (isEbookMode) {
|
||||
// Ebook flow
|
||||
if (useAsinMode && asin) {
|
||||
// ASIN-based ebook selection (user flow from details modal)
|
||||
await selectEbookByAsin(asin, confirmTorrent);
|
||||
} else if (requestId) {
|
||||
// Request ID-based ebook selection (admin flow)
|
||||
await selectEbook(requestId, confirmTorrent);
|
||||
} else {
|
||||
throw new Error('Request ID or ASIN required for ebook selection');
|
||||
}
|
||||
} else if (hasRequestId) {
|
||||
// Existing audiobook flow: select torrent for existing request
|
||||
await selectTorrent(requestId, confirmTorrent);
|
||||
} else {
|
||||
// New audiobook flow: create request with torrent
|
||||
if (!fullAudiobook) {
|
||||
throw new Error('Audiobook data required to create request');
|
||||
}
|
||||
if (!fullAudiobook) throw new Error('Audiobook data required to create request');
|
||||
await requestWithTorrent(fullAudiobook, confirmTorrent);
|
||||
}
|
||||
// Notify parent of successful selection
|
||||
onSuccess?.();
|
||||
// Close modals on success
|
||||
setConfirmTorrent(null);
|
||||
onClose();
|
||||
// Request list will auto-refresh via SWR
|
||||
} catch (err) {
|
||||
// Error already handled by hook
|
||||
console.error('Failed to download:', err);
|
||||
setConfirmTorrent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
const gb = bytes / (1024 ** 3);
|
||||
const mb = bytes / (1024 ** 2);
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${mb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const getQualityBadgeColor = (score: number) => {
|
||||
if (score >= 90) return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
if (score >= 70) return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||
if (score >= 50) return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
};
|
||||
|
||||
// UI text based on mode
|
||||
const modalTitle = isEbookMode ? 'Select Ebook Source' : 'Select Torrent';
|
||||
const searchLabel = isEbookMode ? 'Search Title' : 'Search Title';
|
||||
const searchPlaceholder = isEbookMode ? 'Enter book title to search...' : 'Enter book title to search...';
|
||||
const loadingText = isEbookMode ? 'Searching for ebooks...' : 'Searching for torrents...';
|
||||
const noResultsText = isEbookMode ? 'No ebooks found' : 'No torrents/nzbs found';
|
||||
const modalTitle = isEbookMode ? 'Find Ebook' : 'Find Audiobook';
|
||||
const noResultsText = isEbookMode ? 'No ebooks found' : 'No results found';
|
||||
const resultCountText = (count: number) =>
|
||||
isEbookMode
|
||||
? `Found ${count} ebook${count !== 1 ? 's' : ''}`
|
||||
: `Found ${count} torrent${count !== 1 ? 's' : ''}`;
|
||||
const confirmTitle = isEbookMode ? 'Download Ebook' : 'Download Torrent';
|
||||
? `${count} ebook${count !== 1 ? 's' : ''} found`
|
||||
: `${count} result${count !== 1 ? 's' : ''} found`;
|
||||
const confirmModalTitle = isEbookMode ? 'Download Ebook' : 'Confirm Download';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={modalTitle} size="full">
|
||||
<div className="space-y-4">
|
||||
{/* Search customization - editable for ALL modes */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{searchLabel}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTitle}
|
||||
onChange={(e) => setSearchTitle(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
placeholder={searchPlaceholder}
|
||||
disabled={isSearching}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={performSearch}
|
||||
disabled={isSearching || !searchTitle.trim()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
if (!isOpen || !mounted) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
style={{ height: '100dvh' }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full sm:max-w-2xl lg:max-w-3xl bg-white dark:bg-gray-900 sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-4 sm:slide-in-from-bottom-0 sm:zoom-in-95 duration-300"
|
||||
style={{
|
||||
maxHeight: 'calc(100dvh - env(safe-area-inset-top, 0px) - 1rem)',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-700/50">
|
||||
<h2 className="text-[17px] font-semibold text-gray-900 dark:text-white">{modalTitle}</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 -mr-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
<div className="p-4 sm:p-5 space-y-4">
|
||||
|
||||
{/* Search Bar */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 bg-gray-100/80 dark:bg-white/[0.06] rounded-xl px-3.5 py-2.5 border border-transparent focus-within:border-blue-500/40 focus-within:bg-white dark:focus-within:bg-white/[0.08] focus-within:shadow-sm focus-within:shadow-blue-500/10 transition-all duration-200">
|
||||
<svg className="w-[18px] h-[18px] text-gray-400 flex-shrink-0" 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>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTitle}
|
||||
onChange={(e) => setSearchTitle(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search title..."
|
||||
disabled={isSearching}
|
||||
className="flex-1 bg-transparent outline-none text-[15px] text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 min-w-0"
|
||||
/>
|
||||
{isSearching ? (
|
||||
<div className="flex-shrink-0 w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
|
||||
) : (
|
||||
<button
|
||||
onClick={performSearch}
|
||||
disabled={!searchTitle.trim()}
|
||||
className="flex-shrink-0 px-3 py-1 text-[13px] font-semibold text-white bg-blue-600 hover:bg-blue-700 active:scale-[0.97] rounded-lg transition-all disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1.5 ml-1 truncate">
|
||||
by {audiobook.author}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">By {audiobook.author}</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2.5 px-3.5 py-3 bg-red-50/80 dark:bg-red-500/10 rounded-xl border border-red-200/60 dark:border-red-500/20">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-snug">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isSearching && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-gray-300 border-t-blue-600 rounded-full"></div>
|
||||
<span className="ml-3 text-gray-600 dark:text-gray-400">{loadingText}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Loading Skeleton */}
|
||||
{isSearching && (
|
||||
<div className="space-y-0.5">
|
||||
{skeletonRows.map((widths, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-3.5 rounded-xl animate-pulse">
|
||||
<div className="w-11 h-11 rounded-xl bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="h-3.5 rounded-lg bg-gray-200/80 dark:bg-gray-700/50" style={{ width: widths.title }} />
|
||||
<div className="h-3 rounded-lg bg-gray-100 dark:bg-gray-800/60" style={{ width: widths.meta }} />
|
||||
</div>
|
||||
<div className="w-14 h-[30px] rounded-full bg-gray-200/80 dark:bg-gray-700/50 flex-shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{!isSearching && results.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">{noResultsText}</p>
|
||||
<Button onClick={performSearch} variant="outline" className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Empty State */}
|
||||
{!isSearching && results.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-14">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-3">
|
||||
<svg className="w-7 h-7 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-gray-500 dark:text-gray-400">{noResultsText}</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">Try adjusting your search terms</p>
|
||||
<button
|
||||
onClick={performSearch}
|
||||
className="mt-4 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Search Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results table */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-6">
|
||||
<div className="inline-block min-w-full align-middle px-6">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-12">
|
||||
#
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden sm:table-cell w-24">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Base quality score (0-100): Title/Author match (50) + Format (25) + Seeders (15) + Size (10)">
|
||||
Score
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-16" title="Bonus points from indexer priority and other modifiers">
|
||||
Bonus
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden md:table-cell w-20">
|
||||
Seeds
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase hidden lg:table-cell w-32">
|
||||
{isEbookMode ? 'Source' : 'Indexer'}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase w-24">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{results.map((result) => (
|
||||
<tr key={result.guid} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{result.rank}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div className="truncate">
|
||||
<a
|
||||
href={result.infoUrl || result.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:underline"
|
||||
title={result.title}
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1 flex-wrap">
|
||||
{/* Anna's Archive badge for ebook mode */}
|
||||
{isEbookMode && result.source === 'annas_archive' && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 rounded font-medium">
|
||||
Anna's Archive
|
||||
</span>
|
||||
)}
|
||||
{result.format && (
|
||||
<span className="inline-block px-2 py-0.5 text-xs bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 rounded uppercase">
|
||||
{result.format}
|
||||
</span>
|
||||
)}
|
||||
<span className="sm:hidden inline-block px-2 py-0.5 text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||
{result.size > 0 ? formatSize(result.size) : 'Unknown'}
|
||||
</span>
|
||||
{/* Hide seeds badge for Anna's Archive results */}
|
||||
{!(isEbookMode && result.source === 'annas_archive') && (
|
||||
<span className="md:hidden inline-block px-2 py-0.5 text-xs bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400 rounded">
|
||||
{result.seeders} seeds
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden sm:table-cell">
|
||||
{result.size > 0 ? formatSize(result.size) : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm">
|
||||
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-medium ${getQualityBadgeColor(Math.round(result.score))}`}>
|
||||
{Math.round(result.score)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{result.bonusPoints > 0 ? `+${Math.round(result.bonusPoints)}` : '—'}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 hidden md:table-cell">
|
||||
{isEbookMode && result.source === 'annas_archive' ? (
|
||||
<span className="text-gray-400">N/A</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{result.seeders}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 hidden lg:table-cell">
|
||||
{isEbookMode && result.source === 'annas_archive' ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : (
|
||||
result.indexer
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-3 whitespace-nowrap text-right text-sm">
|
||||
<Button
|
||||
onClick={() => handleDownloadClick(result)}
|
||||
disabled={isDownloading}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
{/* Results List */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{results.map((result) => {
|
||||
const score = Math.round(result.score);
|
||||
const style = getScoreStyle(score);
|
||||
const isUsenet = result.protocol === 'usenet';
|
||||
const isAnnasArchive = isEbookMode && result.source === 'annas_archive';
|
||||
const displayFormat = result.format || result.ebookFormat;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.guid}
|
||||
className="flex items-center gap-3 px-3 py-3 rounded-xl hover:bg-gray-50/80 dark:hover:bg-white/[0.03] transition-colors group"
|
||||
>
|
||||
{/* Score Badge */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-11 h-11 rounded-xl ${style.bg} flex flex-col items-center justify-center`}
|
||||
title={`Score: ${score} (Match: ${Math.round(result.breakdown?.matchScore ?? 0)}, Format: ${Math.round(result.breakdown?.formatScore ?? 0)}, Size: ${Math.round(result.breakdown?.sizeScore ?? 0)}, Seeds: ${Math.round(result.breakdown?.seederScore ?? 0)})`}
|
||||
>
|
||||
<span className={`text-[15px] font-bold leading-none tabular-nums ${style.text}`}>
|
||||
{score}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title Row */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={result.infoUrl || result.guid}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-900 dark:text-white truncate hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={result.title}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{result.title}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="flex items-center gap-1 mt-0.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{/* Rank */}
|
||||
<span className="text-gray-400 dark:text-gray-500 font-medium">#{result.rank}</span>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
|
||||
{/* Indexer / Source */}
|
||||
{isAnnasArchive ? (
|
||||
<span className="text-orange-600 dark:text-orange-400 font-medium">Anna's Archive</span>
|
||||
) : (
|
||||
<span>{result.indexer}</span>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
{result.size > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatSize(result.size)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
{displayFormat && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="px-1 py-px text-[10px] font-semibold uppercase tracking-wide rounded bg-purple-100 dark:bg-purple-500/15 text-purple-700 dark:text-purple-300">
|
||||
{displayFormat}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Protocol (torrent vs usenet) - only show for non-Anna's Archive */}
|
||||
{!isAnnasArchive && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
{isUsenet ? (
|
||||
<span className="flex items-center gap-0.5 text-sky-600 dark:text-sky-400">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
NZB
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<svg className="w-3 h-3 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{result.seeders ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Age */}
|
||||
{result.publishDate && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span>{formatAge(result.publishDate)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Points */}
|
||||
{result.bonusPoints > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600 select-none">·</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">+{Math.round(result.bonusPoints)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={() => handleDownloadClick(result)}
|
||||
disabled={isDownloading}
|
||||
className="flex-shrink-0 px-4 py-1.5 text-[13px] font-semibold text-blue-600 dark:text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 dark:bg-blue-400/10 dark:hover:bg-blue-400/20 rounded-full transition-all active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
Get
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky Footer */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{resultCountText(results.length)}
|
||||
</p>
|
||||
<button
|
||||
onClick={performSearch}
|
||||
disabled={isSearching}
|
||||
className="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Confirmation Overlay */}
|
||||
{confirmTorrent && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 flex items-center justify-center bg-black/40 dark:bg-black/60 backdrop-blur-sm animate-in fade-in duration-150"
|
||||
onClick={() => !isDownloading && setConfirmTorrent(null)}
|
||||
>
|
||||
<div
|
||||
className="mx-5 w-full max-w-sm bg-white dark:bg-gray-800 rounded-2xl shadow-2xl shadow-black/20 overflow-hidden animate-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Confirm Header */}
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-500/10 dark:bg-blue-400/15 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" 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>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-[15px] font-semibold text-gray-900 dark:text-white">
|
||||
{confirmModalTitle}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
This will start the download
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Item Preview */}
|
||||
<div className="bg-gray-50 dark:bg-white/[0.04] rounded-xl px-3.5 py-3 border border-gray-100 dark:border-gray-700/50">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white leading-snug line-clamp-2">
|
||||
{confirmTorrent.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
<span>{confirmTorrent.indexer}</span>
|
||||
{confirmTorrent.size > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span>{formatSize(confirmTorrent.size)}</span>
|
||||
</>
|
||||
)}
|
||||
{confirmTorrent.format && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="uppercase font-medium">{confirmTorrent.format}</span>
|
||||
</>
|
||||
)}
|
||||
{confirmTorrent.protocol === 'usenet' ? (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-sky-600 dark:text-sky-400">NZB</span>
|
||||
</>
|
||||
) : confirmTorrent.seeders !== undefined && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{confirmTorrent.seeders} seeds</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Actions */}
|
||||
<div className="flex border-t border-gray-200/80 dark:border-gray-700/50">
|
||||
<button
|
||||
onClick={() => setConfirmTorrent(null)}
|
||||
disabled={isDownloading}
|
||||
className="flex-1 px-4 py-3 text-[15px] font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-white/[0.03] transition-colors disabled:opacity-40 border-r border-gray-200/80 dark:border-gray-700/50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmDownload}
|
||||
disabled={isDownloading}
|
||||
className="flex-1 px-4 py-3 text-[15px] font-semibold text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-300 dark:border-blue-600 border-t-blue-600 dark:border-t-blue-400 rounded-full animate-spin" />
|
||||
Downloading...
|
||||
</span>
|
||||
) : (
|
||||
'Download'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with result count */}
|
||||
{!isSearching && results.length > 0 && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{resultCountText(results.length)}
|
||||
</p>
|
||||
<Button onClick={performSearch} variant="outline" size="sm">
|
||||
Refresh Results
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={!!confirmTorrent}
|
||||
onClose={() => setConfirmTorrent(null)}
|
||||
onConfirm={handleConfirmDownload}
|
||||
title={confirmTitle}
|
||||
message={`Download "${confirmTorrent?.title}"?`}
|
||||
confirmText="Download"
|
||||
isLoading={isDownloading}
|
||||
variant="primary"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user