Files
ReadMeABook/src/components/audiobooks/AudiobookDetailsModal.tsx
T
xFlawless11x e9d7a2359a feat: add book info modal to admin pending approval cards
Adds an info icon button (top-right of each card) in the Requests
Awaiting Approval section. Clicking it opens AudiobookDetailsModal
with full book details (cover, description, narrator, series, genres,
etc.) and embeds the Approve / Search / Deny action buttons so admins
can review and act without navigating away from the admin panel.

Implementation:
- AudiobookDetailsModal: adds optional `adminActions` prop rendered as
  a second row inside the existing sticky action bar
- admin/page.tsx: adds detailsAsin/detailsRequestId state, info button
  per card (conditional on audibleAsin presence), and AudiobookDetailsModal
  wired with admin action buttons matching the card button behaviour
- Documentation updated: request-approval.md, components.md, TABLEOFCONTENTS.md

Closes #157

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:29:26 -04:00

858 lines
40 KiB
TypeScript

/**
* Component: Audiobook Details Modal
* Documentation: documentation/frontend/components.md
*
* Premium modal design with mobile-first sticky actions
* Matches the Apple-inspired card aesthetic
*/
'use client';
import React, { useEffect, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { createPortal } from 'react-dom';
import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks';
import { useCreateRequest, useEbookStatus, useDownloadStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { usePreferences } from '@/contexts/PreferencesContext';
import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal';
import { ReportIssueModal } from '@/components/audiobooks/ReportIssueModal';
import { ManualImportBrowser } from '@/components/audiobooks/ManualImportBrowser';
import { FolderArrowDownIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { EyeSlashIcon as EyeSlashSolidIcon } from '@heroicons/react/24/solid';
import { fetchWithAuth } from '@/lib/utils/api';
import { useIsIgnored, useToggleIgnore } from '@/lib/hooks/useIgnoredAudiobooks';
interface AudiobookDetailsModalProps {
asin: string;
isOpen: boolean;
onClose: () => void;
onRequestSuccess?: () => void;
onStatusChange?: (newStatus: string) => void;
onIgnoreChange?: (isIgnored: boolean) => void;
isRequested?: boolean;
requestStatus?: string | null;
isAvailable?: boolean;
requestedByUsername?: string | null;
hideRequestActions?: boolean;
hasReportedIssue?: boolean;
aiReason?: string | null;
/** Optional admin action buttons (Approve / Search / Deny) rendered as a second row in the action bar */
adminActions?: React.ReactNode;
}
// Status helper
const getStatusInfo = (isAvailable: boolean, requestStatus: string | null, requestedByUsername: string | null) => {
if (isAvailable || requestStatus === 'completed') {
return { type: 'available', label: 'In Your Library', canRequest: false };
}
const processingStatuses = ['downloading', 'processing', 'downloaded', 'awaiting_import'];
if (requestStatus && processingStatuses.includes(requestStatus)) {
return { type: 'processing', label: 'Processing', canRequest: false };
}
const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval'];
if (requestStatus && pendingStatuses.includes(requestStatus)) {
const label = requestStatus === 'awaiting_approval'
? requestedByUsername ? `Pending Approval (${requestedByUsername})` : 'Pending Approval'
: requestedByUsername ? `Requested by ${requestedByUsername}` : 'Requested';
return { type: 'pending', label, canRequest: false };
}
if (requestStatus === 'denied') {
return { type: 'denied', label: 'Request Denied', canRequest: true };
}
return { type: 'none', label: '', canRequest: true };
};
export function AudiobookDetailsModal({
asin,
isOpen,
onClose,
onRequestSuccess,
onStatusChange,
onIgnoreChange,
isRequested = false,
requestStatus = null,
isAvailable = false,
requestedByUsername = null,
hideRequestActions = false,
hasReportedIssue = false,
aiReason = null,
adminActions,
}: AudiobookDetailsModalProps) {
const { user } = useAuth();
const { squareCovers } = usePreferences();
const { audiobook, audibleBaseUrl, isLoading, error } = useAudiobookDetails(isOpen ? asin : null);
const { createRequest, isLoading: isRequesting } = useCreateRequest();
const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null);
const { downloadAvailable, requestId } = useDownloadStatus(isOpen ? asin : null);
const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin();
const { isIgnored, ignoredId, isLoading: isLoadingIgnore } = useIsIgnored(isOpen ? asin : null);
const { addIgnore, removeIgnore } = useToggleIgnore();
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [mounted, setMounted] = useState(false);
const [showInteractiveSearch, setShowInteractiveSearch] = useState(false);
const [showInteractiveSearchEbook, setShowInteractiveSearchEbook] = useState(false);
const [showReportIssue, setShowReportIssue] = useState(false);
const [showManualImport, setShowManualImport] = useState(false);
const [asinCopied, setAsinCopied] = useState(false);
const [localRequestStatus, setLocalRequestStatus] = useState<string | null>(requestStatus ?? null);
const [isDownloading, setIsDownloading] = useState(false);
const [coverError, setCoverError] = useState(false);
const [isTogglingIgnore, setIsTogglingIgnore] = useState(false);
// Sync local status when the prop changes (e.g. page data refreshes)
useEffect(() => {
setLocalRequestStatus(requestStatus ?? null);
}, [requestStatus]);
const effectiveStatus = localRequestStatus;
const status = getStatusInfo(isAvailable, effectiveStatus, requestedByUsername);
const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest;
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
const showNotification = (message: string, type: 'success' | 'error' = 'success') => {
setToastMessage(message);
setToastType(type);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
const handleRequest = async () => {
if (!user || !audiobook) {
showNotification('Please log in to request audiobooks', 'error');
return;
}
try {
await createRequest(audiobook);
setLocalRequestStatus('pending');
onStatusChange?.('pending');
showNotification('Request created!');
setTimeout(onClose, 1500);
onRequestSuccess?.();
} catch (err) {
showNotification(err instanceof Error ? err.message : 'Failed to create request', 'error');
}
};
const handleInteractiveSearch = () => {
if (!user || !audiobook) {
showNotification('Please log in to request audiobooks', 'error');
return;
}
setShowInteractiveSearch(true);
};
const handleFetchEbook = async () => {
if (!user) {
showNotification('Please log in to request ebooks', 'error');
return;
}
try {
const result = await fetchEbook(asin);
revalidateEbookStatus();
showNotification(result.needsApproval ? 'Ebook request submitted for approval!' : 'Ebook search started!');
} catch (err) {
showNotification(err instanceof Error ? err.message : 'Failed to request ebook', 'error');
}
};
const handleCopyAsin = async () => {
try {
await navigator.clipboard.writeText(asin);
setAsinCopied(true);
setTimeout(() => setAsinCopied(false), 2000);
} catch (err) {
console.error('Failed to copy ASIN:', err);
}
};
const handleDownload = async () => {
if (!requestId) return;
setIsDownloading(true);
try {
const res = await fetchWithAuth(`/api/requests/${requestId}/download-token`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to get download link');
const { downloadUrl } = await res.json();
window.location.href = downloadUrl;
} catch (err) {
console.error('Failed to initiate download:', err);
showNotification('Failed to start download. Please try again.', 'error');
} finally {
setIsDownloading(false);
}
};
const handleToggleIgnore = async () => {
if (!user || !audiobook) return;
setIsTogglingIgnore(true);
try {
if (isIgnored && ignoredId) {
await removeIgnore(ignoredId, asin);
onIgnoreChange?.(false);
showNotification('Removed from ignore list');
} else {
await addIgnore({
asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
});
onIgnoreChange?.(true);
showNotification('Added to ignore list — auto-requests will skip this book');
}
} catch (err) {
showNotification(err instanceof Error ? err.message : 'Failed to update ignore status', 'error');
} finally {
setIsTogglingIgnore(false);
}
};
const formatDuration = (minutes?: number) => {
if (!minutes) return null;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return null;
try {
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return dateString;
}
};
if (!isOpen || !mounted) return null;
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
style={{ height: '100dvh' }}
onClick={onClose}
>
{/* Modal Container - uses dvh for PWA support */}
<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))',
paddingTop: 'env(safe-area-inset-top, 0px)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Mobile: Sticky Header with Close */}
<div className="sticky top-0 z-20 flex items-center justify-between px-4 py-3 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-700/50 sm:hidden">
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Audiobook Details</span>
<button
onClick={onClose}
className="p-2 -mr-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5 text-gray-500" 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>
{/* Desktop: Close Button */}
<button
onClick={onClose}
className="hidden sm:flex absolute top-4 right-4 z-20 p-2 rounded-full bg-gray-100/80 dark:bg-gray-800/80 backdrop-blur-sm hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Close"
>
<svg className="w-5 h-5 text-gray-600 dark: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>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-10 h-10 border-3 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
{/* Error State */}
{error && !isLoading && (
<div className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-gray-900 dark:text-gray-100 font-medium">Failed to load details</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Please try again later</p>
</div>
)}
{/* Content */}
{audiobook && !isLoading && (
<div className="p-4 sm:p-6 lg:p-8">
{/* Hero Section - Cover + Title/Author */}
<div className="flex flex-col sm:flex-row gap-5 sm:gap-6">
{/* Cover Art */}
<div className="flex-shrink-0 mx-auto sm:mx-0">
<div className={`
relative overflow-hidden rounded-2xl shadow-xl shadow-black/20 dark:shadow-black/40
${squareCovers ? 'w-40 sm:w-44 lg:w-52 aspect-square' : 'w-32 sm:w-40 lg:w-48 aspect-[2/3]'}
${status.type === 'available' ? 'ring-2 ring-emerald-400/60' : ''}
`}>
{audiobook.coverArtUrl && !coverError ? (
<Image
src={audiobook.coverArtUrl}
alt=""
fill
className="object-cover"
sizes="200px"
priority
onError={() => setCoverError(true)}
/>
) : (
<Image
src="/placeholder_cover.svg"
alt=""
fill
className="object-cover"
sizes="200px"
/>
)}
{/* Rating Badge */}
{audiobook.rating && audiobook.rating > 0 && (
<div className="absolute top-2 left-2 flex items-center gap-1 px-2 py-1 rounded-lg bg-black/60 backdrop-blur-sm text-white text-xs font-medium">
<svg className="w-3.5 h-3.5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<span>{audiobook.rating.toFixed(1)}</span>
</div>
)}
</div>
</div>
{/* Title & Author */}
<div className="flex-1 text-center sm:text-left min-w-0">
<h2 className="text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-gray-100 leading-tight">
{audiobook.title}
</h2>
<p className="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-300">
{audiobook.authorAsin ? (
<Link
href={`/authors/${audiobook.authorAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
{audiobook.author}
</Link>
) : (
audiobook.author
)}
</p>
{audiobook.narrator && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Narrated by {audiobook.narrator}
</p>
)}
{audiobook.series && (
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{audiobook.seriesAsin ? (
<Link
href={`/series/${audiobook.seriesAsin}`}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors"
>
{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}
</Link>
) : (
<span>{audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''}</span>
)}
</p>
)}
{/* Status Badge */}
{status.type !== 'none' && (
<div className="mt-4 inline-flex">
<span className={`
inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
${status.type === 'available' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' : ''}
${status.type === 'processing' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : ''}
${status.type === 'pending' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' : ''}
${status.type === 'denied' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' : ''}
`}>
{status.type === 'available' && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
{status.type === 'processing' && (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{status.label}
</span>
</div>
)}
{/* Issue Reported Badge */}
{isAvailable && hasReportedIssue && (
<div className="mt-2 inline-flex">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Issue Reported
</span>
</div>
)}
{/* Report Issue Button - inline with metadata, not in action bar */}
{isAvailable && !hasReportedIssue && user && (
<div className="mt-2 inline-flex">
<button
onClick={() => setShowReportIssue(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
</svg>
Report Issue
</button>
</div>
)}
{/* Quick Metadata */}
<div className="mt-4 flex flex-wrap items-center justify-center sm:justify-start gap-3 text-sm text-gray-500 dark:text-gray-400">
{audiobook.durationMinutes && (
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatDuration(audiobook.durationMinutes)}
</span>
)}
{audiobook.releaseDate && (
<span>{formatDate(audiobook.releaseDate)}</span>
)}
</div>
</div>
</div>
{/* Genres */}
{audiobook.genres && audiobook.genres.length > 0 && (
<div className="mt-6 flex flex-wrap gap-2">
{audiobook.genres.map((genre: string) => (
<span
key={genre}
className="px-3 py-1 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm rounded-full"
>
{genre}
</span>
))}
</div>
)}
{/* Description */}
{audiobook.description && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Summary
</h3>
<p className="text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-wrap text-[15px]">
{audiobook.description}
</p>
</div>
)}
{/* AI Recommendation Reasoning */}
{aiReason && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Why This Was Recommended
</h3>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
<p className="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
{aiReason}
</p>
</div>
</div>
)}
{/* Details Grid */}
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700/50">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Details
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
{/* ASIN */}
<div>
<p className="text-gray-500 dark:text-gray-400">ASIN</p>
<button
onClick={handleCopyAsin}
className="flex items-center gap-1.5 font-mono text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
{asin}
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{asinCopied ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
)}
</svg>
</button>
</div>
{/* Audible Link */}
<div>
<p className="text-gray-500 dark:text-gray-400">Source</p>
<a
href={`${audibleBaseUrl}/pd/${asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-orange-600 dark:text-orange-400 hover:underline"
>
Audible
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
{/* Download Link - subtle utility, visible from any context */}
{isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (
<div>
<p className="text-gray-500 dark:text-gray-400">Download</p>
<button
onClick={handleDownload}
disabled={isDownloading}
className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
aria-label={isDownloading ? 'Preparing download...' : 'Download audiobook files'}
>
{isDownloading ? (
<>
<svg className="w-3.5 h-3.5 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Preparing...</span>
</>
) : (
<>
<svg className="w-3.5 h-3.5 flex-shrink-0" 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>
<span>Download files</span>
</>
)}
</button>
</div>
)}
</div>
</div>
{/* Ebook Status */}
{ebookStatus?.hasActiveEbookRequest && (
<div className="mt-4 p-3 rounded-xl bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800/50">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-sm">
<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>
<span>
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval'
? 'Pending Approval'
: ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded'
? 'Available'
: 'In Progress'}
</span>
</div>
</div>
)}
</div>
)}
</div>
{/* Sticky Action Bar - hidden when opened from read-only contexts */}
{audiobook && !isLoading && !hideRequestActions && (
<div
className="sticky bottom-0 z-20 p-4 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom, 0px))' }}
>
<div className="flex items-center gap-3">
{/* Main Action */}
<div className="flex-1">
{status.type === 'available' ? (
<button
disabled
className="w-full py-3 px-4 rounded-xl font-semibold text-emerald-700 dark:text-emerald-400 bg-emerald-100 dark:bg-emerald-900/30"
>
In Your Library
</button>
) : status.canRequest ? (
<button
onClick={handleRequest}
disabled={isRequesting || !user}
className="w-full py-3 px-4 rounded-xl font-semibold text-white bg-blue-600 hover:bg-blue-700 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRequesting ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Requesting...
</span>
) : !user ? 'Sign in to Request' : 'Request Audiobook'}
</button>
) : (
<button
disabled
className={`
w-full py-3 px-4 rounded-xl font-semibold
${status.type === 'processing' ? 'text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30' : ''}
${status.type === 'pending' ? 'text-blue-700 dark:text-blue-400 bg-blue-100 dark:bg-blue-900/30' : ''}
${status.type === 'denied' ? 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30' : ''}
`}
>
{status.type === 'processing' && (
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Processing
</span>
)}
{status.type === 'pending' && status.label}
{status.type === 'denied' && 'Request Denied'}
</button>
)}
</div>
{/* Interactive Search - only if not available and user has permission */}
{status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={handleInteractiveSearch}
disabled={!user}
className="p-3 rounded-xl bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-colors disabled:opacity-50"
title="Interactive Search"
>
<svg className="w-6 h-6" 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>
</button>
)}
{/* Manual Import - admin only, hidden during active processing and completed states */}
{user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && (
<button
onClick={() => setShowManualImport(true)}
className="p-3 rounded-xl bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400 hover:bg-teal-200 dark:hover:bg-teal-900/50 transition-colors"
title="Manual Import"
>
<FolderArrowDownIcon className="w-6 h-6" />
</button>
)}
{/* Ebook Buttons - only when available and enabled */}
{canShowEbookButtons && user && (
<>
<button
onClick={handleFetchEbook}
disabled={isFetchingEbook}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors disabled:opacity-50"
title="Grab Ebook"
>
{isFetchingEbook ? (
<svg className="w-6 h-6 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="w-6 h-6" 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>
)}
</button>
{(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && (
<button
onClick={() => setShowInteractiveSearchEbook(true)}
className="p-3 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 hover:bg-orange-200 dark:hover:bg-orange-900/50 transition-colors"
title="Search Ebook Sources"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</button>
)}
</>
)}
{/* Ignore Toggle - always visible when user is logged in */}
{user && !isLoadingIgnore && (
<button
onClick={handleToggleIgnore}
disabled={isTogglingIgnore}
className={`p-3 rounded-xl transition-colors disabled:opacity-50 ${
isIgnored
? 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
: 'bg-gray-100 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
title={isIgnored ? 'Stop Ignoring — auto-requests will resume for this book' : 'Ignore from Auto-Requests'}
>
{isIgnored ? (
<EyeSlashSolidIcon className="w-6 h-6" />
) : (
<EyeSlashIcon className="w-6 h-6" />
)}
</button>
)}
</div>
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */}
{adminActions && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-amber-200 dark:border-amber-700/50">
{adminActions}
</div>
)}
</div>
)}
{/* Toast Notification */}
{showToast && (
<div className={`
absolute bottom-20 left-1/2 -translate-x-1/2 z-30
px-4 py-2.5 rounded-xl shadow-lg backdrop-blur-md
${toastType === 'success' ? 'bg-emerald-500/95 text-white' : 'bg-red-500/95 text-white'}
animate-in fade-in slide-in-from-bottom-2 duration-200
`}>
<p className="text-sm font-medium whitespace-nowrap">{toastMessage}</p>
</div>
)}
</div>
</div>
);
return (
<>
{createPortal(modalContent, document.body)}
{/* Interactive Search Modal (Audiobook) */}
{showInteractiveSearch && audiobook && createPortal(
<div className="fixed inset-0 z-[60]">
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearch}
onClose={() => {
setShowInteractiveSearch(false);
onClose();
}}
onSuccess={() => {
onRequestSuccess?.();
}}
audiobook={{
title: audiobook.title,
author: audiobook.author,
}}
fullAudiobook={audiobook}
/>
</div>,
document.body
)}
{/* Interactive Search Modal (Ebook) */}
{showInteractiveSearchEbook && audiobook && createPortal(
<div className="fixed inset-0 z-[60]">
<InteractiveTorrentSearchModal
isOpen={showInteractiveSearchEbook}
onClose={() => {
setShowInteractiveSearchEbook(false);
revalidateEbookStatus();
}}
onSuccess={() => {
revalidateEbookStatus();
showNotification('Ebook download started!');
}}
asin={asin}
audiobook={{
title: audiobook.title,
author: audiobook.author,
}}
searchMode="ebook"
/>
</div>,
document.body
)}
{/* Report Issue Modal */}
{showReportIssue && audiobook && (
<ReportIssueModal
isOpen={showReportIssue}
onClose={() => setShowReportIssue(false)}
onSuccess={() => {
setShowReportIssue(false);
showNotification('Issue reported!');
}}
asin={asin}
bookTitle={audiobook.title}
bookAuthor={audiobook.author}
coverArtUrl={audiobook.coverArtUrl}
/>
)}
{/* Manual Import Browser */}
{showManualImport && audiobook && (
<ManualImportBrowser
isOpen={showManualImport}
onClose={() => setShowManualImport(false)}
onSuccess={() => {
setLocalRequestStatus('processing');
onStatusChange?.('processing');
showNotification('Import started — files are being processed');
onRequestSuccess?.();
}}
audiobook={{
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
coverArtUrl: audiobook.coverArtUrl,
}}
/>
)}
</>
);
}