diff --git a/public/RMAB_1024x1024_APPICON.png b/public/RMAB_1024x1024_APPICON.png new file mode 100644 index 0000000..f65f5c6 Binary files /dev/null and b/public/RMAB_1024x1024_APPICON.png differ diff --git a/public/RMAB_180x180_APPICON.png b/public/RMAB_180x180_APPICON.png new file mode 100644 index 0000000..3598046 Binary files /dev/null and b/public/RMAB_180x180_APPICON.png differ diff --git a/public/manifest.json b/public/manifest.json index efd2c66..bd512aa 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -8,15 +8,10 @@ "theme_color": "#1e3a5f", "icons": [ { - "src": "/RMAB_1024x1024_ICON.png", + "src": "/RMAB_1024x1024_APPICON.png", "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable" - }, - { - "src": "/rmab_32x32.png", - "sizes": "32x32", - "type": "image/png" } ] } diff --git a/src/app/bookdate/page.tsx b/src/app/bookdate/page.tsx index cfd01ed..7ffae39 100644 --- a/src/app/bookdate/page.tsx +++ b/src/app/bookdate/page.tsx @@ -414,6 +414,7 @@ export default function BookDatePage() { requestStatus={currentRec.requestStatus} isAvailable={currentRec.isAvailable} requestedByUsername={currentRec.requestedByUsername} + hideRequestActions /> ) : null; })()} diff --git a/src/app/globals.css b/src/app/globals.css index 943757b..176c9f6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -170,3 +170,29 @@ body { -webkit-backface-visibility: hidden; transform-origin: center center; } + +/* Premium Shimmer Animation for Skeletons */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +/* Smooth Toast Slide In */ +@keyframes toast-slide-in { + 0% { + opacity: 0; + transform: translate(-50%, 20px); + } + 100% { + opacity: 1; + transform: translate(-50%, 0); + } +} + +.animate-toast-in { + animation: toast-slide-in 0.3s ease-out; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cdfcfe4..d9e751e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,7 +30,8 @@ export const metadata: Metadata = { ], shortcut: "/rmab_icon.ico", apple: [ - { url: "/RMAB_1024x1024_ICON.png", sizes: "1024x1024", type: "image/png" }, + { url: "/RMAB_180x180_APPICON.png", sizes: "180x180", type: "image/png" }, + { url: "/RMAB_1024x1024_APPICON.png", sizes: "1024x1024", type: "image/png" }, ], }, appleWebApp: { diff --git a/src/components/audiobooks/AudiobookCard.tsx b/src/components/audiobooks/AudiobookCard.tsx index 5d8675e..4d7c0b6 100644 --- a/src/components/audiobooks/AudiobookCard.tsx +++ b/src/components/audiobooks/AudiobookCard.tsx @@ -1,14 +1,15 @@ /** * Component: Audiobook Card * Documentation: documentation/frontend/components.md + * + * Premium "Cover First" design - Apple-inspired aesthetic + * The cover is the hero. Metadata supports, never overwhelms. */ 'use client'; import React, { useState } from 'react'; import Image from 'next/image'; -import { Button } from '@/components/ui/Button'; -import { StatusBadge } from '@/components/requests/StatusBadge'; import { AudiobookDetailsModal } from '@/components/audiobooks/AudiobookDetailsModal'; import { useCreateRequest } from '@/lib/hooks/useRequests'; import { useAuth } from '@/contexts/AuthContext'; @@ -22,10 +23,31 @@ interface AudiobookCardProps { squareCovers?: boolean; } +// Status configuration for elegant display +const getStatusConfig = (audiobook: Audiobook) => { + if (audiobook.isAvailable || audiobook.requestStatus === 'completed') { + return { type: 'available', label: 'In Library', color: 'emerald' }; + } + + const processingStatuses = ['downloading', 'processing', 'downloaded', 'awaiting_import']; + if (audiobook.requestStatus && processingStatuses.includes(audiobook.requestStatus)) { + return { type: 'processing', label: 'Processing', color: 'amber' }; + } + + const pendingStatuses = ['pending', 'awaiting_search', 'searching', 'awaiting_approval']; + if (audiobook.requestStatus && pendingStatuses.includes(audiobook.requestStatus)) { + return { type: 'pending', label: 'Requested', color: 'blue' }; + } + + if (audiobook.requestStatus === 'denied') { + return { type: 'denied', label: 'Denied', color: 'red' }; + } + + return null; +}; + export function AudiobookCard({ audiobook, - isRequested = false, - requestStatus, onRequestSuccess, squareCovers = false, }: AudiobookCardProps) { @@ -35,223 +57,194 @@ export function AudiobookCard({ const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); - const handleRequest = async () => { + const status = getStatusConfig(audiobook); + + const handleRequest = async (e: React.MouseEvent) => { + e.stopPropagation(); if (!user) { setError('Please log in to request audiobooks'); + setTimeout(() => setError(null), 3000); return; } try { await createRequest(audiobook); setShowToast(true); - setTimeout(() => setShowToast(false), 3000); + setTimeout(() => setShowToast(false), 2500); onRequestSuccess?.(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create request'); - setTimeout(() => setError(null), 5000); + setTimeout(() => setError(null), 4000); } }; - const formatDuration = (minutes?: number) => { - if (!minutes) return null; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `${hours}h ${mins}m`; - }; + // Determine if we can request this book + const canRequest = !status || status.type === 'denied'; return ( <> -
- {/* Cover Art - Clickable */} -
setShowModal(true)} - > - {audiobook.coverArtUrl ? ( - {`Cover - ) : ( -
- - - -
- )} +
setShowModal(true)} + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setShowModal(true)} + role="button" + aria-label={`View details for ${audiobook.title} by ${audiobook.author}`} + > + {/* Cover Container - The Hero */} +
+ {/* Cover Image with Premium Shadow */} +
+ {/* Cover Art */} + {audiobook.coverArtUrl ? ( + + ) : ( +
+ + + +
+ )} - {/* Hover overlay for click hint */} -
-
- - - - + {/* Hover Overlay with Actions - Desktop Only + pointer-events-none by default so taps on mobile pass through to card + Only enable pointer-events on devices that support hover */} +
+
+ {/* Quick Action Button */} + {canRequest ? ( + + ) : status?.type === 'available' ? ( +
+ In Your Library +
+ ) : ( +
+ {status?.type === 'processing' && ( + + + + + + Processing + + )} + {status?.type === 'pending' && 'Requested'} + {status?.type === 'denied' && 'Request Denied'} +
+ )} +
+ + {/* Subtle Status Indicator (visible when not hovered) */} + {status && ( +
+ )} + + {/* Rating Badge - Top Left, Elegant */} + {audiobook.rating && audiobook.rating > 0 && ( +
+ + + + {audiobook.rating.toFixed(1)} +
+ )}
- - {/* Availability Badge */} - {audiobook.isAvailable && ( -
- - - - Available -
- )} - - {/* Processing Badge - show when status is 'downloaded' */} - {audiobook.requestStatus === 'downloaded' && ( -
- - - - - Processing -
- )}
- {/* Content */} -
- {/* Title - Clickable */} -

setShowModal(true)} - > + {/* Metadata - Clean, Minimal */} +
+

{audiobook.title}

- - {/* Author */} -

- By {audiobook.author} -

- - {/* Narrator */} - {audiobook.narrator && ( -

- Narrated by {audiobook.narrator} +

+ {audiobook.author}

- )} - - {/* Metadata Row - Fixed height for alignment */} -
- {/* Rating - Only show if > 0 (0 means no rating) */} - {audiobook.rating && audiobook.rating > 0 && ( -
- - - - {audiobook.rating.toFixed(1)} -
- )}
- {/* Status or Action */} -
- {(() => { - // Check if book is already available in Plex or completed/available status - if (audiobook.isAvailable || audiobook.requestStatus === 'completed') { - return ( -
- - In Your Library - -
- ); - } - - // Check if book is requested and in progress (non-re-requestable statuses) - const inProgressStatuses = ['pending', 'awaiting_search', 'searching', 'downloading', 'processing', 'downloaded', 'awaiting_import', 'awaiting_approval', 'denied']; - if (audiobook.isRequested && audiobook.requestStatus && inProgressStatuses.includes(audiobook.requestStatus)) { - // Determine button text based on status - let buttonText; - let buttonClass = 'w-full cursor-not-allowed opacity-75'; - - if (audiobook.requestStatus === 'downloaded') { - buttonText = 'Processing...'; - } else if (audiobook.requestStatus === 'awaiting_approval') { - buttonText = audiobook.requestedByUsername - ? `Pending Approval (${audiobook.requestedByUsername})` - : 'Pending Approval'; - } else if (audiobook.requestStatus === 'denied') { - buttonText = 'Request Denied'; - buttonClass = 'w-full cursor-not-allowed opacity-75 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30'; - } else { - buttonText = audiobook.requestedByUsername - ? `Requested by ${audiobook.requestedByUsername}` - : 'Requested'; - } - - return ( - - ); - } - - // For failed/warn/cancelled or no request - show Request button - return ( - - ); - })()} -
- - {/* Error Message */} - {error && ( -

{error}

+ {/* Toast Notifications - Floating */} + {(showToast || error) && ( +
+

+ {showToast ? 'Request created!' : error} +

+
)} +

- {/* Success Toast */} - {showToast && ( -

- ✓ Request created successfully! -

- )} -
-
- - {/* Details Modal */} - setShowModal(false)} - onRequestSuccess={onRequestSuccess} - isRequested={audiobook.isRequested} - requestStatus={audiobook.requestStatus} - isAvailable={audiobook.isAvailable} - requestedByUsername={audiobook.requestedByUsername} - /> + {/* Details Modal */} + setShowModal(false)} + onRequestSuccess={onRequestSuccess} + isRequested={audiobook.isRequested} + requestStatus={audiobook.requestStatus} + isAvailable={audiobook.isAvailable} + requestedByUsername={audiobook.requestedByUsername} + /> ); } diff --git a/src/components/audiobooks/AudiobookDetailsModal.tsx b/src/components/audiobooks/AudiobookDetailsModal.tsx index 243a074..9d0efef 100644 --- a/src/components/audiobooks/AudiobookDetailsModal.tsx +++ b/src/components/audiobooks/AudiobookDetailsModal.tsx @@ -1,6 +1,9 @@ /** * 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'; @@ -8,11 +11,10 @@ import React, { useEffect, useState } from 'react'; import Image from 'next/image'; import { createPortal } from 'react-dom'; -import { Button } from '@/components/ui/Button'; -import { StatusBadge } from '@/components/requests/StatusBadge'; import { useAudiobookDetails } from '@/lib/hooks/useAudiobooks'; import { useCreateRequest, useEbookStatus, useFetchEbookByAsin } from '@/lib/hooks/useRequests'; import { useAuth } from '@/contexts/AuthContext'; +import { usePreferences } from '@/contexts/PreferencesContext'; import { InteractiveTorrentSearchModal } from '@/components/requests/InteractiveTorrentSearchModal'; interface AudiobookDetailsModalProps { @@ -24,8 +26,35 @@ interface AudiobookDetailsModalProps { requestStatus?: string | null; isAvailable?: boolean; requestedByUsername?: string | null; + hideRequestActions?: boolean; } +// 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, @@ -35,24 +64,25 @@ export function AudiobookDetailsModal({ requestStatus = null, isAvailable = false, requestedByUsername = null, + hideRequestActions = false, }: AudiobookDetailsModalProps) { const { user } = useAuth(); + const { squareCovers } = usePreferences(); const { audiobook, isLoading, error } = useAudiobookDetails(isOpen ? asin : null); const { createRequest, isLoading: isRequesting } = useCreateRequest(); const { ebookStatus, revalidate: revalidateEbookStatus } = useEbookStatus(isOpen && isAvailable ? asin : null); const { fetchEbook, isLoading: isFetchingEbook } = useFetchEbookByAsin(); + const [showToast, setShowToast] = useState(false); - const [toastMessage, setToastMessage] = useState('Request created successfully!'); - const [requestError, setRequestError] = useState(null); + 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 [asinCopied, setAsinCopied] = useState(false); - // Determine if ebook buttons should be shown - const canShowEbookButtons = isAvailable && - ebookStatus?.ebookSourcesEnabled && - !ebookStatus?.hasActiveEbookRequest; + const status = getStatusInfo(isAvailable, requestStatus, requestedByUsername); + const canShowEbookButtons = isAvailable && ebookStatus?.ebookSourcesEnabled && !ebookStatus?.hasActiveEbookRequest; useEffect(() => { setMounted(true); @@ -69,115 +99,49 @@ export function AudiobookDetailsModal({ }; }, [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) { - setRequestError('Please log in to request audiobooks'); + showNotification('Please log in to request audiobooks', 'error'); return; } try { await createRequest(audiobook); - setToastMessage('Request created successfully!'); - setShowToast(true); - setTimeout(() => { - setShowToast(false); - onClose(); - }, 2000); + showNotification('Request created!'); + setTimeout(onClose, 1500); onRequestSuccess?.(); } catch (err) { - setRequestError(err instanceof Error ? err.message : 'Failed to create request'); - setTimeout(() => setRequestError(null), 5000); + showNotification(err instanceof Error ? err.message : 'Failed to create request', 'error'); } }; const handleInteractiveSearch = () => { if (!user || !audiobook) { - setRequestError('Please log in to request audiobooks'); + showNotification('Please log in to request audiobooks', 'error'); return; } - - // Just show the interactive search modal - no request created yet setShowInteractiveSearch(true); }; - const handleInteractiveSearchClose = () => { - // Clean up state - setShowInteractiveSearch(false); - - // Close the details modal too - onClose(); - }; - - const handleInteractiveSearchSuccess = () => { - // Request was created and torrent was selected successfully - onRequestSuccess?.(); - }; - const handleFetchEbook = async () => { if (!user) { - setRequestError('Please log in to request ebooks'); + showNotification('Please log in to request ebooks', 'error'); return; } try { const result = await fetchEbook(asin); revalidateEbookStatus(); - - if (result.needsApproval) { - setToastMessage('Ebook request submitted for approval!'); - } else { - setToastMessage('Ebook search started!'); - } - setShowToast(true); - setTimeout(() => { - setShowToast(false); - }, 3000); + showNotification(result.needsApproval ? 'Ebook request submitted for approval!' : 'Ebook search started!'); } catch (err) { - setRequestError(err instanceof Error ? err.message : 'Failed to request ebook'); - setTimeout(() => setRequestError(null), 5000); - } - }; - - const handleInteractiveSearchEbook = () => { - if (!user) { - setRequestError('Please log in to request ebooks'); - return; - } - setShowInteractiveSearchEbook(true); - }; - - const handleInteractiveSearchEbookClose = () => { - setShowInteractiveSearchEbook(false); - revalidateEbookStatus(); - }; - - const handleInteractiveSearchEbookSuccess = () => { - revalidateEbookStatus(); - setToastMessage('Ebook download started!'); - setShowToast(true); - setTimeout(() => { - setShowToast(false); - }, 3000); - }; - - const formatDuration = (minutes?: number) => { - if (!minutes) return null; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `${hours} hr ${mins} min`; - }; - - const formatDate = (dateString?: string) => { - if (!dateString) return null; - try { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - } catch { - return dateString; + showNotification(err instanceof Error ? err.message : 'Failed to request ebook', 'error'); } }; @@ -191,203 +155,229 @@ export function AudiobookDetailsModal({ } }; + 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()} > - {/* Close Button */} + {/* Mobile: Sticky Header with Close */} +
+ Audiobook Details + +
+ + {/* Desktop: Close Button */} - {/* Loading State */} - {isLoading && ( -
-
-
- )} - - {/* Error State */} - {error && !isLoading && ( -
-
-

- Failed to load audiobook details -

-

- Please try again later -

+ {/* Scrollable Content */} +
+ {/* Loading State */} + {isLoading && ( +
+
-
- )} + )} - {/* Content */} - {audiobook && !isLoading && ( -
- {/* Header Section */} -
- {/* Cover Art */} -
-
- {audiobook.coverArtUrl ? ( - {`Cover - ) : ( -
- - - + {/* Error State */} + {error && !isLoading && ( +
+
+ + + +
+

Failed to load details

+

Please try again later

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

+ {audiobook.title} +

+

+ {audiobook.author} +

+ {audiobook.narrator && ( +

+ Narrated by {audiobook.narrator} +

+ )} + + {/* Status Badge */} + {status.type !== 'none' && ( +
+ + {status.type === 'available' && ( + + + + )} + {status.type === 'processing' && ( + + + + + )} + {status.label} +
)} + + {/* Quick Metadata */} +
+ {audiobook.durationMinutes && ( + + + + + {formatDuration(audiobook.durationMinutes)} + + )} + {audiobook.releaseDate && ( + {formatDate(audiobook.releaseDate)} + )} +
- {/* Metadata */} -
- {/* Title */} -
-

- {audiobook.title} -

+ {/* Genres */} + {audiobook.genres && audiobook.genres.length > 0 && ( +
+ {audiobook.genres.map((genre: string) => ( + + {genre} + + ))}
+ )} - {/* Author */} -
-

By

-

- {audiobook.author} + {/* Description */} + {audiobook.description && ( +

+

+ Summary +

+

+ {audiobook.description}

+ )} - {/* Narrator */} - {audiobook.narrator && ( -
-

Narrated by

-

- {audiobook.narrator} -

-
- )} - - {/* Metadata Grid */} -
- {/* Rating - Always show header, display 'Not Found' if no rating */} -
-

Rating

- {audiobook.rating && audiobook.rating > 0 ? ( -
-
- {[...Array(5)].map((_, i) => ( - - - - ))} -
- - {Number(audiobook.rating).toFixed(1)} - -
- ) : ( -

Not Found

- )} -
- - {/* Duration */} - {audiobook.durationMinutes && ( -
-

Length

-

- {formatDuration(audiobook.durationMinutes)} -

-
- )} - - {/* Release Date */} - {audiobook.releaseDate && ( -
-

Release Date

-

- {formatDate(audiobook.releaseDate)} -

-
- )} - + {/* Details Grid */} +
+

+ Details +

+
{/* ASIN */}
-

ASIN

+

ASIN

@@ -395,328 +385,158 @@ export function AudiobookDetailsModal({ {/* Audible Link */}
-

View Details

+

Source

- Audible.com - - + Audible + +
- - {/* Availability Status */} - {isAvailable && ( -
-

Status

-
- - - - In Your Library -
-
- )}
+
- {/* Genres */} - {audiobook.genres && audiobook.genres.length > 0 && ( -
-

Genres

-
- {audiobook.genres.map((genre: string) => ( - - {genre} - - ))} -
+ {/* 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 bookdate */} + {audiobook && !isLoading && !hideRequestActions && ( +
+
+ {/* Main Action */} +
+ {status.type === 'available' ? ( + + ) : status.canRequest ? ( + + ) : ( + )}
-
- {/* Description */} - {audiobook.description && ( -
-

- Publisher's Summary -

-
- {audiobook.description} -
-
- )} - - {/* Action Buttons */} -
- {(() => { - // Use props from card instead of fetched audiobook data for request status - // Check if book is already available in library or completed status - if (isAvailable || requestStatus === 'completed') { - return ( - <> -
-
- - Available in Your Library - -
-
- - {/* Ebook Buttons - Only shown when audiobook is available and ebook sources enabled */} - {canShowEbookButtons && user && ( - <> - {/* Grab Ebook Button */} - - - {/* Interactive Search Ebook Button */} - - - )} - - {/* Show ebook request status if one exists */} - {ebookStatus?.hasActiveEbookRequest && ( -
- - - - - Ebook: {ebookStatus.existingEbookStatus === 'awaiting_approval' - ? 'Pending Approval' - : ebookStatus.existingEbookStatus === 'available' || ebookStatus.existingEbookStatus === 'downloaded' - ? 'Available' - : 'In Progress'} - -
- )} - - ); - } - - // Check if book is requested and in progress - const inProgressStatuses = [ - 'pending', - 'awaiting_search', - 'searching', - 'downloading', - 'processing', - 'downloaded', - 'awaiting_import', - 'awaiting_approval', - 'denied', - ]; - if ( - isRequested && - requestStatus && - inProgressStatuses.includes(requestStatus) - ) { - // Determine button text and styling based on status - let buttonText; - let buttonClass = 'w-full cursor-not-allowed opacity-75'; - - if (requestStatus === 'downloaded') { - buttonText = 'Processing...'; - } else if (requestStatus === 'awaiting_approval') { - buttonText = requestedByUsername - ? `Pending Approval (${requestedByUsername})` - : 'Pending Approval'; - } else if (requestStatus === 'denied') { - buttonText = 'Request Denied'; - buttonClass = 'w-full cursor-not-allowed opacity-75 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30'; - } else { - buttonText = requestedByUsername - ? `Requested by ${requestedByUsername}` - : 'Already Requested'; - } - - return ( -
- -
- ); - } - - // For failed/warn/cancelled or no request - show Request button - return ( -
- -
- ); - })()} - - {/* Interactive Search Button - only show if not already available */} - {!isAvailable && requestStatus !== 'completed' && ( + {/* Interactive Search - only if not available */} + {status.type !== 'available' && ( )} - + {/* Ebook Buttons - only when available and enabled */} + {canShowEbookButtons && user && ( + <> + + + + )}
+
+ )} - {/* Error Message */} - {requestError && ( -
-

{requestError}

-
- )} - - {/* Success Toast */} - {showToast && ( -
-

- ✓ {toastMessage} -

-
- )} + {/* Toast Notification */} + {showToast && ( +
+

{toastMessage}

)}
@@ -726,40 +546,49 @@ export function AudiobookDetailsModal({ return ( <> {createPortal(modalContent, document.body)} - {/* Interactive Search Modal (Audiobook) - render with higher z-index to appear above details modal */} + + {/* 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) - render with higher z-index to appear above details modal */} + + {/* 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 )} diff --git a/src/components/audiobooks/AudiobookGrid.tsx b/src/components/audiobooks/AudiobookGrid.tsx index 4c671b3..f6c1da1 100644 --- a/src/components/audiobooks/AudiobookGrid.tsx +++ b/src/components/audiobooks/AudiobookGrid.tsx @@ -1,6 +1,8 @@ /** * Component: Audiobook Grid * Documentation: documentation/frontend/components.md + * + * Premium grid layout with generous spacing and elegant skeletons */ 'use client'; @@ -18,21 +20,20 @@ interface AudiobookGridProps { squareCovers?: boolean; // true = square (1:1), false = rectangle (2:3) } -// Helper function to get grid classes based on card size -// IMPORTANT: Classes must be explicit strings (not template literals) for Tailwind purging +// Grid classes with generous spacing for premium feel +// IMPORTANT: Classes must be explicit strings for Tailwind purging function getGridClasses(size: number): string { const sizeMap: Record = { - 1: 'grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10', // Smallest + 1: 'grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10', 2: 'grid-cols-3 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-9', 3: 'grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8', 4: 'grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7', - 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5', // Default + 5: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5', 6: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4', 7: 'grid-cols-2 md:grid-cols-3', 8: 'grid-cols-2', - 9: 'grid-cols-1', // Largest + 9: 'grid-cols-1', }; - return sizeMap[size] || sizeMap[5]; } @@ -48,9 +49,9 @@ export function AudiobookGrid({ if (isLoading) { return ( -
- {Array.from({ length: 8 }).map((_, i) => ( - +
+ {Array.from({ length: 10 }).map((_, i) => ( + ))}
); @@ -58,27 +59,19 @@ export function AudiobookGrid({ if (audiobooks.length === 0) { return ( -
- - - -

{emptyMessage}

+
+
+ + + +
+

{emptyMessage}

); } return ( -
+
{audiobooks.map((audiobook) => ( - {/* Cover Art Skeleton */} -
+
+ {/* Cover Skeleton */} +
+ {/* Shimmer overlay */} +
+
- {/* Content Skeleton */} -
- {/* Title */} -
-
- - {/* Author */} -
- - {/* Metadata */} -
- - {/* Button */} -
+ {/* Text Skeleton */} +
+
+
); diff --git a/src/contexts/PreferencesContext.tsx b/src/contexts/PreferencesContext.tsx index e7325bd..0a8a5b4 100644 --- a/src/contexts/PreferencesContext.tsx +++ b/src/contexts/PreferencesContext.tsx @@ -23,7 +23,7 @@ const PreferencesContext = createContext(und const DEFAULT_PREFERENCES: Preferences = { cardSize: 5, - squareCovers: false, + squareCovers: true, }; const STORAGE_KEY = 'preferences';