From fec9d31c8a45bf11a9210ec76e2f8b189b2f7eb8 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:17:37 +0000 Subject: [PATCH] Frontend improvements for Audible provider (#769) - Added square artwork support - Added dedicated length and narrator icons - Added audiobook info to release and details modals - Moved search options button to accommodate larger Audible search fields --- shelfmark/metadata_providers/__init__.py | 3 + shelfmark/metadata_providers/audible.py | 19 +-- src/frontend/src/components/DetailsModal.tsx | 35 ++--- src/frontend/src/components/ReleaseModal.tsx | 46 +++++-- src/frontend/src/components/SearchBar.tsx | 124 +++++++++++------- .../src/components/resultsViews/CardView.tsx | 4 +- .../components/resultsViews/CompactView.tsx | 12 +- .../src/components/resultsViews/ListView.tsx | 40 +++--- .../components/shared/DisplayFieldIcon.tsx | 12 ++ src/frontend/src/types/index.ts | 1 + src/frontend/src/utils/bookTransformers.ts | 2 + 11 files changed, 185 insertions(+), 113 deletions(-) diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 5d61ede..95beb53 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -194,6 +194,9 @@ class BookMetadata: search_title: Optional[str] = None # Cleaner title for search queries (provider-specific) search_author: Optional[str] = None # Cleaner author for search queries (provider-specific) + # Cover aspect ratio hint for the frontend ("portrait" or "square") + cover_aspect: Optional[str] = None + # Provider-specific display fields for cards/lists display_fields: List[DisplayField] = field(default_factory=list) diff --git a/shelfmark/metadata_providers/audible.py b/shelfmark/metadata_providers/audible.py index ee99c67..a07f55e 100644 --- a/shelfmark/metadata_providers/audible.py +++ b/shelfmark/metadata_providers/audible.py @@ -270,6 +270,13 @@ class AudibleProvider(MetadataProvider): label="Author", description="Search Audible by author name", ), + TextSearchField( + key="series", + label="Series", + description="Browse a series in reading order", + suggestions_endpoint="/api/metadata/field-options?provider=audible&field=series", + suggestions_min_query_length=2, + ), TextSearchField( key="title", label="Title", @@ -290,13 +297,6 @@ class AudibleProvider(MetadataProvider): label="Keywords", description="Search Audible by keywords", ), - TextSearchField( - key="series", - label="Series", - description="Browse a series in reading order", - suggestions_endpoint="/api/metadata/field-options?provider=audible&field=series", - suggestions_min_query_length=2, - ), ] capabilities = [ MetadataCapability( @@ -764,12 +764,12 @@ class AudibleProvider(MetadataProvider): runtime_value = _format_runtime(item.get("lengthMinutes")) if runtime_value: - display_fields.append(DisplayField(label="Length", value=runtime_value, icon="book")) + display_fields.append(DisplayField(label="Length", value=runtime_value, icon="clock")) narrator_value = _format_narrator_value(narrators) if narrator_value: label = "Narrator" if len(narrators) == 1 else "Narrators" - display_fields.append(DisplayField(label=label, value=narrator_value, icon="users")) + display_fields.append(DisplayField(label=label, value=narrator_value, icon="microphone")) book_format = str(item.get("bookFormat") or "").strip() if book_format: @@ -786,6 +786,7 @@ class AudibleProvider(MetadataProvider): isbn_10=isbn_10, isbn_13=isbn_13, cover_url=str(item.get("imageUrl") or "").strip() or None, + cover_aspect="square", description=_sanitize_description(item.get("description")) or _sanitize_description(item.get("summary")), publisher=str(item.get("publisher") or "").strip() or None, diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index 1b92c89..da7d45c 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -128,8 +128,11 @@ export const DetailsModal = ({ // Use provider display name from backend, fall back to capitalized provider name const providerDisplay = book.provider_display_name || (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : ''); + const isSquareCover = book.cover_aspect === 'square'; const artworkMaxHeight = 'calc(90vh - 220px)'; - const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))'; + const artworkMaxWidth = isSquareCover + ? 'min(45vw, 400px, calc(90vh - 220px))' + : 'min(45vw, 520px, calc((90vh - 220px) / 1.6))'; const additionalInfo = book.info && Object.keys(book.info).length > 0 ? Object.entries(book.info).filter(([key]) => { @@ -251,29 +254,15 @@ export const DetailsModal = ({

)} - - {/* Other display fields (pages, editions) - Universal mode only */} - {otherDisplayFields && otherDisplayFields.length > 0 && ( -
- {otherDisplayFields.map(field => ( - - {field.icon === 'book' && ( - - - - )} - {field.icon === 'editions' && ( - - - - )} - {field.label}: - {field.value} - - ))} -
- )} + {/* Other display fields (length, narrator, format, etc.) - Universal mode only */} + {otherDisplayFields && otherDisplayFields.map(field => ( +
+

{field.label}

+

{field.value}

+
+ ))} + {/* ISBN - Universal mode only */} {isMetadata && (book.isbn_13 || book.isbn_10) && ( diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index c8933b2..e2113e5 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -1196,8 +1196,10 @@ export const ReleaseModal = ({ const ratingsField = book.display_fields.find(f => f.icon === 'ratings'); const usersField = book.display_fields.find(f => f.icon === 'users'); const pagesField = book.display_fields.find(f => f.icon === 'book'); + const lengthField = book.display_fields.find(f => f.icon === 'clock'); + const narratorField = book.display_fields.find(f => f.icon === 'microphone'); - return { starField, ratingsField, usersField, pagesField }; + return { starField, ratingsField, usersField, pagesField, lengthField, narratorField }; }, [book?.display_fields]); const getReleaseActionMode = useCallback( @@ -1318,15 +1320,15 @@ export const ReleaseModal = ({ ) : (
No cover
@@ -1338,7 +1340,7 @@ export const ReleaseModal = ({
@@ -1350,15 +1352,15 @@ export const ReleaseModal = ({ ) : (
No cover
@@ -1402,14 +1404,14 @@ export const ReleaseModal = ({ Book cover ) : ( -
+
No cover
)} -
+
{/* Metadata row */}
{book.year && {book.year}} @@ -1433,6 +1435,22 @@ export const ReleaseModal = ({ {displayFields?.pagesField && ( {displayFields.pagesField.value} pages )} + {displayFields?.lengthField && ( + + + + + {displayFields.lengthField.value} + + )} + {displayFields?.narratorField && ( + + + + + {displayFields.narratorField.value} + + )}
{/* Series info */} @@ -1494,7 +1512,7 @@ export const ReleaseModal = ({ )} {/* Links row */} -
+
{(book.isbn_13 || book.isbn_10) && ( ISBN: {book.isbn_13 || book.isbn_10} diff --git a/src/frontend/src/components/SearchBar.tsx b/src/frontend/src/components/SearchBar.tsx index ea69942..046b264 100644 --- a/src/frontend/src/components/SearchBar.tsx +++ b/src/frontend/src/components/SearchBar.tsx @@ -651,14 +651,48 @@ export const SearchBar = forwardRef(({
{showContentTypeSelector && (
-
- Content +
+ Content + {onAdvancedToggle && ( + + )}
)} -
-
- Search By +
+
+ Search By + {!showContentTypeSelector && onAdvancedToggle && ( + + )}
{queryTargets.map((target) => { @@ -699,7 +767,7 @@ export const SearchBar = forwardRef(({ onClick={() => handleQueryTargetSelect(target.key)} title={target.description || target.label} aria-label={target.label} - className={`min-w-0 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors flex items-center gap-2 ${ + className={`min-w-0 rounded-xl border px-3 py-2 text-sm font-medium transition-colors flex items-center gap-2 ${ isActive ? `${searchMode === 'direct' ? 'bg-sky-700' : 'bg-emerald-600'} text-white` : 'hover-surface' }`} style={isActive @@ -714,46 +782,6 @@ export const SearchBar = forwardRef(({
- {onAdvancedToggle && ( -
- -
- )}
)} diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx index 1a85c89..7d0666a 100644 --- a/src/frontend/src/components/resultsViews/CardView.tsx +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -62,7 +62,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > -
+
{/* Series position badge */} {showSeriesPosition && book.series_position != null && ( @@ -156,7 +156,7 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta
{book.year || '-'} - + f.icon !== 'editions')} />
) : (
diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx index 04b996c..2d95ee7 100644 --- a/src/frontend/src/components/resultsViews/CompactView.tsx +++ b/src/frontend/src/components/resultsViews/CompactView.tsx @@ -4,7 +4,7 @@ import { useSearchMode } from '../../contexts/SearchModeContext'; import { BookActionButton } from '../BookActionButton'; import { BookTargetDropdown } from '../BookTargetDropdown'; import { bookSupportsTargets } from '../../utils/bookTargetLoader'; -import { DisplayFieldBadges } from '../shared'; +import { DisplayFieldBadges, DisplayFieldIcon } from '../shared'; const SkeletonLoader = () => (
@@ -159,7 +159,15 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? ( - + <> + f.icon !== 'editions' && f.icon !== 'microphone')} className="text-xs opacity-70" /> + {book.display_fields.find(f => f.icon === 'microphone') && ( +
+ + {book.display_fields.find(f => f.icon === 'microphone')!.value} +
+ )} + ) : (
{book.language || '-'} diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx index 4826e5f..bce9eab 100644 --- a/src/frontend/src/components/resultsViews/ListView.tsx +++ b/src/frontend/src/components/resultsViews/ListView.tsx @@ -18,14 +18,16 @@ interface ListViewProps { onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; } -const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { +const ListViewThumbnail = ({ preview, title, coverAspect }: { preview?: string; title?: string; coverAspect?: string }) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); + const isSquare = coverAspect === 'square'; + const sizeClass = isSquare ? 'w-10 h-10 sm:w-14 sm:h-14' : 'w-7 h-10 sm:w-10 sm:h-14'; if (!preview || imageError) { return (
No Cover @@ -34,14 +36,14 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin } return ( -
+
{!imageLoaded && (
)} {title setImageLoaded(true)} onError={() => setImageError(true)} @@ -116,12 +118,12 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto {/* Universal mode uses separate columns for each display field, direct mode uses language/format/size */}
{/* Thumbnail */}
- +
{/* Title and Author */} @@ -149,7 +151,7 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto {/* Mobile universal mode info */}
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? ( - book.display_fields.slice(0, 2).map((field, idx) => ( + book.display_fields.filter(f => f.icon !== 'editions').slice(0, 2).map((field, idx) => ( {field.value} @@ -171,18 +173,26 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto {/* Universal mode: Display fields as separate columns - Desktop only */} {searchMode === 'universal' && ( <> - {/* First display field column */} -
- {book.display_fields && book.display_fields[0] ? ( - + {/* Rating column */} +
+ {book.display_fields?.find(f => f.icon === 'star') ? ( + f.icon === 'star')!} /> ) : ( - )}
- {/* Second display field column */} -
- {book.display_fields && book.display_fields[1] ? ( - + {/* Length column */} +
+ {book.display_fields?.find(f => f.icon === 'clock' || f.icon === 'book') ? ( + f.icon === 'clock' || f.icon === 'book')!} /> + ) : ( + - + )} +
+ {/* Narrator column */} +
+ {book.display_fields?.find(f => f.icon === 'microphone') ? ( + f.icon === 'microphone')!} /> ) : ( - )} diff --git a/src/frontend/src/components/shared/DisplayFieldIcon.tsx b/src/frontend/src/components/shared/DisplayFieldIcon.tsx index 21620b8..116a20f 100644 --- a/src/frontend/src/components/shared/DisplayFieldIcon.tsx +++ b/src/frontend/src/components/shared/DisplayFieldIcon.tsx @@ -25,6 +25,18 @@ export function DisplayFieldIcon({ icon, className = 'w-3 h-3' }: DisplayFieldIc ); + case 'microphone': + return ( + + + + ); + case 'clock': + return ( + + + + ); case 'editions': return ( diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index f51847b..8b0d4f8 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -43,6 +43,7 @@ export interface Book { genres?: string[]; source_url?: string; // Link to book on provider's site display_fields?: DisplayField[]; // Provider-specific display data + cover_aspect?: 'portrait' | 'square'; // Cover art aspect ratio hint // Series info (if book is part of a series) series_id?: string; // Provider-specific series ID series_name?: string; // Name of the series diff --git a/src/frontend/src/utils/bookTransformers.ts b/src/frontend/src/utils/bookTransformers.ts index 6617f0e..e551f64 100644 --- a/src/frontend/src/utils/bookTransformers.ts +++ b/src/frontend/src/utils/bookTransformers.ts @@ -13,6 +13,7 @@ export interface MetadataBookData { isbn_10?: string; isbn_13?: string; cover_url?: string; + cover_aspect?: 'portrait' | 'square'; description?: string; publisher?: string; publish_year?: number; @@ -79,6 +80,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book { year: data.publish_year?.toString(), language: data.language, preview: data.cover_url, + cover_aspect: data.cover_aspect, publisher: data.publisher, description: data.description, provider: data.provider,