/** * 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(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 = (
{/* Modal Container - uses dvh for PWA support */}
e.stopPropagation()} > {/* Mobile: Sticky Header with Close */}
Audiobook Details
{/* Desktop: Close Button */} {/* Scrollable Content */}
{/* Loading State */} {isLoading && (
)} {/* Error State */} {error && !isLoading && (

Failed to load details

Please try again later

)} {/* Content */} {audiobook && !isLoading && (
{/* Hero Section - Cover + Title/Author */}
{/* Cover Art */}
{audiobook.coverArtUrl && !coverError ? ( setCoverError(true)} /> ) : ( )} {/* Rating Badge */} {audiobook.rating && audiobook.rating > 0 && (
{audiobook.rating.toFixed(1)}
)}
{/* Title & Author */}

{audiobook.title}

{audiobook.authorAsin ? ( { e.stopPropagation(); onClose(); }} className="hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors" > {audiobook.author} ) : ( audiobook.author )}

{audiobook.narrator && (

Narrated by {audiobook.narrator}

)} {audiobook.series && (

{audiobook.seriesAsin ? ( { e.stopPropagation(); onClose(); }} className="hover:text-emerald-600 dark:hover:text-emerald-400 transition-colors" > {audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''} ) : ( {audiobook.series}{audiobook.seriesPart ? `, Book ${audiobook.seriesPart}` : ''} )}

)} {/* Status Badge */} {status.type !== 'none' && (
{status.type === 'available' && ( )} {status.type === 'processing' && ( )} {status.label}
)} {/* Issue Reported Badge */} {isAvailable && hasReportedIssue && (
Issue Reported
)} {/* Report Issue Button - inline with metadata, not in action bar */} {isAvailable && !hasReportedIssue && user && (
)} {/* Quick Metadata */}
{audiobook.durationMinutes && ( {formatDuration(audiobook.durationMinutes)} )} {audiobook.releaseDate && ( {formatDate(audiobook.releaseDate)} )}
{/* Genres */} {audiobook.genres && audiobook.genres.length > 0 && (
{audiobook.genres.map((genre: string) => ( {genre} ))}
)} {/* Description */} {audiobook.description && (

Summary

{audiobook.description}

)} {/* AI Recommendation Reasoning */} {aiReason && (

Why This Was Recommended

{aiReason}

)} {/* Details Grid */}

Details

{/* ASIN */}

ASIN

{/* Audible Link */}

Source

Audible
{/* Download Link - subtle utility, visible from any context */} {isAvailable && downloadAvailable && requestId && user?.permissions?.download !== false && (

Download

)}
{/* Ebook Status */} {ebookStatus?.hasActiveEbookRequest && (
Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval' ? 'Pending Approval' : ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded' ? 'Available' : 'In Progress'}
)}
)}
{/* Sticky Action Bar - hidden when opened from read-only contexts */} {audiobook && !isLoading && !hideRequestActions && (
{/* Main Action */}
{status.type === 'available' ? ( ) : status.canRequest ? ( ) : ( )}
{/* Interactive Search - only if not available and user has permission */} {status.type !== 'available' && (user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && ( )} {/* Manual Import - admin only, hidden during active processing and completed states */} {user?.role === 'admin' && !isAvailable && !['downloading', 'processing', 'searching', 'downloaded', 'completed', 'available'].includes(effectiveStatus || '') && ( )} {/* Ebook Buttons - only when available and enabled */} {canShowEbookButtons && user && ( <> {(user?.role === 'admin' || user?.permissions?.interactiveSearch !== false) && ( )} )} {/* Ignore Toggle - always visible when user is logged in */} {user && !isLoadingIgnore && ( )}
{/* Admin Actions Row (Approve / Search / Deny) — injected by admin pages */} {adminActions && (
{adminActions}
)}
)} {/* Toast Notification */} {showToast && (

{toastMessage}

)}
); return ( <> {createPortal(modalContent, document.body)} {/* Interactive Search Modal (Audiobook) */} {showInteractiveSearch && audiobook && createPortal(
{ setShowInteractiveSearch(false); onClose(); }} onSuccess={() => { onRequestSuccess?.(); }} audiobook={{ title: audiobook.title, author: audiobook.author, }} fullAudiobook={audiobook} />
, document.body )} {/* Interactive Search Modal (Ebook) */} {showInteractiveSearchEbook && audiobook && createPortal(
{ setShowInteractiveSearchEbook(false); revalidateEbookStatus(); }} onSuccess={() => { revalidateEbookStatus(); showNotification('Ebook download started!'); }} asin={asin} audiobook={{ title: audiobook.title, author: audiobook.author, }} searchMode="ebook" />
, document.body )} {/* Report Issue Modal */} {showReportIssue && audiobook && ( setShowReportIssue(false)} onSuccess={() => { setShowReportIssue(false); showNotification('Issue reported!'); }} asin={asin} bookTitle={audiobook.title} bookAuthor={audiobook.author} coverArtUrl={audiobook.coverArtUrl} /> )} {/* Manual Import Browser */} {showManualImport && audiobook && ( 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, }} /> )} ); }