/** * Component: Request Actions Dropdown * Documentation: documentation/admin-features/request-deletion.md * * Dropdown menu for admin actions on requests */ 'use client'; 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 { request: { requestId: string; title: string; author: string; status: string; type?: 'audiobook' | 'ebook'; asin?: string | null; torrentUrl?: string | null; downloadAttempts?: number; customSearchTerms?: string | null; }; onDelete: (requestId: string, title: string) => void; onManualSearch: (requestId: string) => Promise; onCancel: (requestId: string) => Promise; onRetryDownload?: (requestId: string) => Promise; onViewDetails?: (asin: string) => void; onFetchEbook?: (requestId: string) => Promise; onSearchTermsUpdated?: () => void; ebookSidecarEnabled?: boolean; annasArchiveBaseUrl?: string; isLoading?: boolean; } export function RequestActionsDropdown({ request, onDelete, onManualSearch, onCancel, onRetryDownload, onViewDetails, onFetchEbook, onSearchTermsUpdated, ebookSidecarEnabled = false, annasArchiveBaseUrl = 'https://annas-archive.gl', isLoading = false, }: RequestActionsDropdownProps) { 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 const isEbook = request.type === 'ebook'; // View Details: available when ASIN exists (audiobook requests only) const canViewDetails = !isEbook && !!request.asin && !!onViewDetails; // Determine available actions based on status const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status); const canAdjustSearchTerms = ['pending', 'failed', 'awaiting_search', 'searching'].includes(request.status); const canRetryDownload = request.status === 'failed' && (request.downloadAttempts ?? 0) > 0 && !!onRetryDownload; const canCancel = ['pending', 'searching', 'downloading', 'awaiting_search'].includes(request.status); const canDelete = true; // Admins can always delete // View Source: For ebooks, extract MD5 from slow download URL and link to Anna's Archive // For audiobooks and indexer-sourced ebooks, show indexer page URL (not magnet links) let viewSourceUrl: string | null = null; if (isEbook && request.torrentUrl) { // torrentUrl for ebooks can be: // 1. JSON array of slow download URLs (Anna's Archive) - extract MD5 // 2. Plain URL string (indexer source) - use directly try { const urls = JSON.parse(request.torrentUrl); if (Array.isArray(urls) && urls.length > 0) { const md5Match = urls[0].match(/\/slow_download\/([a-f0-9]{32})\//i); if (md5Match) { viewSourceUrl = `${annasArchiveBaseUrl.replace(/\/+$/, '')}/md5/${md5Match[1]}`; } } } catch { // Not JSON - it's a plain URL from indexer source // Use it directly if it's not a magnet link if (!request.torrentUrl.startsWith('magnet:')) { viewSourceUrl = request.torrentUrl; } } } else if (request.torrentUrl && !request.torrentUrl.startsWith('magnet:')) { viewSourceUrl = request.torrentUrl; } const canViewSource = !!viewSourceUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status); // Ebook actions (Grab Ebook, Interactive Search Ebook) only for audiobook requests const canFetchEbook = !isEbook && ebookSidecarEnabled && ['downloaded', 'available'].includes(request.status); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); const handleManualSearch = async () => { setIsOpen(false); try { await onManualSearch(request.requestId); } catch (error) { console.error('Failed to trigger manual search:', error); } }; const handleInteractiveSearch = () => { setIsOpen(false); if (isEbook) { setShowInteractiveSearchEbook(true); } else { 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}"?`)) { try { await onCancel(request.requestId); } catch (error) { console.error('Failed to cancel request:', error); } } }; const handleDelete = () => { setIsOpen(false); 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); } } }; const handleViewDetails = () => { setIsOpen(false); if (request.asin && onViewDetails) { onViewDetails(request.asin); } }; // Dropdown menu content (rendered via portal) const dropdownMenu = isOpen && style && (
{/* View Details */} {canViewDetails && ( )} {/* Divider after View Details */} {canViewDetails && (canSearch || canViewSource || canFetchEbook || canCancel || canDelete) && (
)} {/* Manual Search */} {canSearch && ( )} {/* Interactive Search */} {canSearch && ( )} {/* Adjust Search Terms */} {canAdjustSearchTerms && ( )} {/* View Source */} {canViewSource && viewSourceUrl && ( setIsOpen(false)} 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" > View Source )} {/* Grab E-book (automatic) */} {canFetchEbook && ( )} {/* Interactive Search E-book */} {canFetchEbook && ( )} {/* Retry Download */} {canRetryDownload && ( )} {/* Divider if we have search/view/retry actions and other actions */} {(canSearch || canViewSource || canFetchEbook || canRetryDownload) && (canCancel || canDelete) && (
)} {/* Cancel */} {canCancel && ( )} {/* Divider before delete */} {canDelete && (canSearch || canRetryDownload || canCancel) && (
)} {/* Delete */} {canDelete && ( )}
); return ( <> {/* Three-dot menu button */}
{/* Dropdown menu (rendered via portal) */} {typeof window !== 'undefined' && dropdownMenu && createPortal(dropdownMenu, document.body)} {/* Interactive Search Modal (Audiobook) */} setShowInteractiveSearch(false)} requestId={request.requestId} audiobook={{ title: request.title, author: request.author, }} customSearchTerms={request.customSearchTerms} /> {/* Interactive Search Modal (Ebook) */} setShowInteractiveSearchEbook(false)} requestId={request.requestId} audiobook={{ title: request.title, author: request.author, }} searchMode="ebook" customSearchTerms={request.customSearchTerms} /> {/* Adjust Search Terms Modal */} setShowAdjustSearchTerms(false)} requestId={request.requestId} title={request.title} author={request.author} currentSearchTerms={request.customSearchTerms} onSuccess={onSearchTermsUpdated} /> ); }